diff --git a/.env.example b/.env.example index 2206a5d566..ba419dac43 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,11 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Default: 20128 PORT=20128 +# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath. +# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware. +# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute +# OMNIROUTE_BASE_PATH= + # Split-port mode: serve Dashboard and API on separate ports for network isolation. # Used by: src/lib/runtime/ports.ts — overrides PORT for each service. # API_PORT=20129 @@ -413,6 +418,15 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128 # Do not include /v1; if included accidentally it will be normalized away. # OMNIROUTE_PUBLIC_BASE_URL=http://192.168.0.15:20128 +# Absolute provider plugin manifest URL advertised to sidecar clients. +# Used by: open-sse/config/providerPluginManifestUrl.ts. When unset, OmniRoute +# derives the URL from request origin or HOST/PORT using OMNIROUTE_PUBLIC_PROTOCOL. +# OMNIROUTE_PROVIDER_MANIFEST_URL=https://omniroute.example.com/api/v1/provider-plugin-manifest + +# Protocol used when deriving provider plugin manifest URLs without a request origin. +# Used by: open-sse/config/providerPluginManifestUrl.ts. Defaults to http. +# OMNIROUTE_PUBLIC_PROTOCOL=http + # Max wait time for an async chatgpt-web image to land via the celsius # WebSocket, in milliseconds. Default 180000 (3 minutes). Increase during # upstream queue-deep windows ("Lots of people are creating images right now"). @@ -568,6 +582,8 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_CONTINUE_BIN=cn # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen +# CLI_AUGGIE_BIN=auggie +# AUGGIE_BIN=auggie # Override the Hermes Agent home directory (where OmniRoute reads/writes the # Hermes CLI config). Matches the env var the Hermes PowerShell installer sets @@ -869,6 +885,8 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # QODER_PERSONAL_ACCESS_TOKEN= # QODER_CLI_WORKSPACE= # OMNIROUTE_QODER_WORKSPACE= +# Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). +# QODER_CLI_CONFIG_DIR= # ── Blackbox Web validated-token override (issue #2252) ── # Used by: open-sse/executors/blackbox-web.ts. Blackbox `/api/chat` rejects @@ -1037,6 +1055,12 @@ CURSOR_USER_AGENT="Cursor/3.4" # fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min). # OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000 +# ── Firecrawl web-fetch executor ── +# Point at a self-hosted Firecrawl instance (defaults to the public cloud API). +# When set to a non-cloud base URL, the API key becomes optional. +# FIRECRAWL_BASE_URL=https://api.firecrawl.dev +# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s) + # ── ChatGPT TLS sidecar (Firefox-fingerprinted client) ── # Used by: open-sse/services/chatgptTlsClient.ts — wire-level timeout for # the bogdanfinn/tls-client koffi binding and the JS-side grace window @@ -1442,6 +1466,13 @@ APP_LOG_TO_FILE=true # CLIPROXYAPI_PORT=5544 # CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api +# ── Mux embedded service ── +# Override the port where the embedded Mux (coder/mux) agent-orchestration +# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0. +# Rarely needed — defaults to 8322. +# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts +# MUX_SERVICE_PORT=8322 + # ── Local hostnames (Docker networking) ── # Comma-separated additional hostnames treated as "local" for provider routing. # Used by: open-sse/config/providerRegistry.ts — allows Docker service names. @@ -1464,6 +1495,20 @@ APP_LOG_TO_FILE=true # healthy-result cache window under high concurrency. # PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS=2000 +# Background proxy health scheduler (src/lib/proxyHealth/scheduler.ts). +# Periodically probes every registered proxy and (optionally) removes dead ones. +# Set "false" to disable the scheduler entirely. Default: enabled. +# PROXY_HEALTH_ENABLED=true +# Sweep interval in ms (minimum 60000). Default: 600000 (10min). +# PROXY_HEALTH_INTERVAL_MS=600000 +# Reachability probe target for the scheduler and the auto-test endpoint. +# Point it at an internal/self-hosted URL to avoid the public default. +# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip +# Set "true" to let the scheduler auto-remove proxies after repeated failures. +# PROXY_AUTO_REMOVE=false +# Consecutive failures before an auto-remove fires. Default: 3. +# PROXY_AUTO_REMOVE_AFTER=3 + # Allow OAuth and provider validation flows to bypass a pinned proxy and connect # directly when proxy reachability pre-checks fail. Default: false. # Also configurable from Dashboard > Settings > Feature Flags. @@ -1675,6 +1720,15 @@ APP_LOG_TO_FILE=true # FREE_PROXY_IPLOCATE_ENABLED=false # FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols +# ── Free Proxy Pool (Webshare source) ── +# Used by: src/lib/freeProxyProviders/webshare.ts +# Paid, per-account proxy list — requires FREE_PROXY_WEBSHARE_API_KEY to activate, +# regardless of FREE_PROXY_WEBSHARE_ENABLED. +# FREE_PROXY_WEBSHARE_ENABLED=true +# FREE_PROXY_WEBSHARE_API_KEY= +# FREE_PROXY_WEBSHARE_API_URL=https://proxy.webshare.io/api/v2/proxy/list/ +# FREE_PROXY_WEBSHARE_MAX=500 + # ── Vercel Relay ── # Used by: src/app/api/settings/proxy/vercel-deploy/route.ts # Hides the "Deploy Relay" button when set to false. @@ -1879,6 +1933,10 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # duplicated). Falls back to TS path via X-Bifrost-Fallback header on # timeout/failure. See bin/omniroute for the local-redis companion. # BIFROST_BASE_URL= +# Port the supervised Bifrost embedded service binds to (127.0.0.1:), read by +# src/lib/services/bootstrap.ts when OmniRoute manages the Bifrost sidecar lifecycle. +# Default: 8080. +# BIFROST_PORT=8080 # API key for the Bifrost gateway (sent as Authorization: Bearer ...). If # unset, the route expects the request to carry a valid OmniRoute API key; # this key is for gateway-side auth only. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 906c02bb7f..f5cd3afdfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -624,7 +624,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-vitest: name: Vitest (MCP / autoCombo / UI components) @@ -676,7 +676,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" node-26-compat-build: name: Node 26 Compatibility Build @@ -729,7 +729,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-coverage-shard: name: Coverage Shard (${{ matrix.shard }}/8) @@ -770,7 +770,7 @@ jobs: --exclude=tests/** \ --exclude=**/*.test.* \ node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \ - --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" - name: Upload raw shard coverage if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index bc9a307d3b..e578122d0b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -145,4 +145,4 @@ jobs: --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts - "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" diff --git a/CHANGELOG.md b/CHANGELOG.md index 468959521e..15e699bbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ --- +## [3.8.44] — TBD + +### ✨ New Features + +- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) +- **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. +- **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. (thanks @KooshaPari) +- **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). (thanks @KooshaPari) +- **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. (thanks @KooshaPari) +- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) +- **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) +- **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) +- **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. +- **feat(claude-code):** add an opt-in auto-permission classifier compat mode (off/auto/always) for Claude Code, toggleable from the CLI Code settings. +- **feat(providers):** add optional client-identity header profiles for compatible nodes — preset User-Agent/fingerprint headers (e.g. matching a known CLI) merged into the existing customHeaders field. + +### 🔧 Bug Fixes + +- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) + +### 📝 Maintenance + +- **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PR→release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` — that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959)) + +- **API validation:** add a `validatedJsonBody(request, schema)` helper in `src/shared/validation/helpers.ts` that fuses JSON body parsing and Zod validation into a single call, returning either the type-narrowed data or a ready-to-return 400 `NextResponse` with the standard error envelope. Salvaged from the closed refactor PR #5075 (Tier 1 portable helper) with a focused 6-case regression test. Co-authored-by: KooshaPari + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features @@ -74,6 +102,8 @@ - **providers (CLI profile auto-sync):** opt-in CLI profile auto-sync toggles, including Claude Code auto-sync, so generated CLI profiles can track provider changes automatically. ([#5755](https://github.com/diegosouzapw/OmniRoute/pull/5755) — thanks @diegosouzapw) +- **feat(minimax):** surface MiniMax M3 `` reasoning as `reasoning_content` on OpenAI-format provider tiers. (thanks @zmf963) + ### 🔧 Bug Fixes - **fix(opencode):** stop fabricating `User-Agent: opencode/local` and `x-opencode-client: cli` headers when the client sends none — the executor-dedup refactor ([#5720](https://github.com/diegosouzapw/OmniRoute/pull/5720)) accidentally re-introduced header fabrication, violating the forward-only contract (inventing opencode-internal values risks upstream rejection). Restored to forward-only: those headers are emitted only when a real client source is present. Regression guard: `tests/unit/opencode-executor.test.ts`. (thanks @diegosouzapw) diff --git a/CLAUDE.md b/CLAUDE.md index dcfda7c548..12d4e1c41e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -541,6 +541,9 @@ the stale-enforcement added in Fase 6A.3. 19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". 20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. 21. **Release-freeze — the release branch is frozen to campaign merges while a `/generate-release` is running.** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a) and closes it once the release PR squash-merges to `main`. Before merging **any** PR into the active `release/vX.Y.Z` branch, every campaign workflow (`/review-issues`, `/review-prs`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active, **HOLD the merge** (leave the PR ready and open; do NOT merge to the release branch), tell the operator, and resume once the freeze lifts. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. +22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening): + - **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show :` or `git diff -- `; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent). + - **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create *this* session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.) --- diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs index 2bdedfa64b..6badd3aa3c 100644 --- a/bin/cli/commands/setup-claude.mjs +++ b/bin/cli/commands/setup-claude.mjs @@ -56,7 +56,7 @@ export function buildProfileSettings(modelId, baseUrl, cfg) { * behaviorally identical. Writes `/profiles//settings.json` * (directory-per-profile); never touches the active/default Claude config. * @param {Array} models - * @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string}} opts + * @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string, log?:(line:string)=>void}} opts * @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>} */ export async function syncClaudeProfilesFromModels(models, opts = {}) { @@ -64,6 +64,12 @@ export async function syncClaudeProfilesFromModels(models, opts = {}) { const profilesRoot = join(claudeHome, "profiles"); const baseUrl = opts.baseUrl; const dryRun = Boolean(opts.dryRun); + // Injectable dry-run printer (#5959): under the node:test runner, a child + // process writing multi-byte UTF-8 (the "──" box-drawing heading) to stdout + // corrupts the runner's V8-serialized event stream ~50% of the time + // ("Unable to deserialize cloned data due to invalid or unsupported + // version"). Tests inject a collector; the CLI default stays console.log. + const log = opts.log ?? console.log; const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; if (!dryRun && !existsSync(profilesRoot)) { @@ -96,8 +102,8 @@ export async function syncClaudeProfilesFromModels(models, opts = {}) { const content = buildProfileSettings(id, baseUrl, cfg); if (dryRun) { - console.log(`\n── [dry-run] ${filePath} ──`); - console.log(content); + log(`\n── [dry-run] ${filePath} ──`); + log(content); } else { mkdirSync(dir, { recursive: true }); writeFileSync(filePath, content, "utf8"); diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 4114fd995a..ba285566ed 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,10 @@ { "_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": 1995, + "count": 2015, + "_rebaseline_2026_07_03_6007_sidecar_manifest": "2007->2015. PR #6007 (re-cut) adds sidecar/provider-manifest header wiring and the public manifest URL helper; the feature files themselves introduce 0 new complexity violations (check-complexity flags none in providerPluginManifestUrl.ts). The +8 vs the 2007 release baseline is inherited release/v3.8.44 drift absorbed at merge (check:complexity measures 2015 on the current release tip). Tighten via --update next cycle / at /generate-release Phase 0.", + "_rebaseline_2026_07_02_v3844_ci_observed": "2006->2007 (+1). Local Ubuntu measures 2006 on this tree; the GitHub fast-gates runner measures 2007 (same local-vs-CI off-by-one already documented in _rebaseline_2026_06_26_v3838_release_fast_gate). Use the CI-observed value so the gate is deterministic where it actually runs.", + "_rebaseline_2026_07_02_v3844_post_5939": "2003->2006 (+3). Inherited drift from the release/v3.8.44 merges after 3a3d618fe (#5809 audio translations et al.), surfaced by PR fix#5959: check:complexity measures 2006 on the pristine base (cbd08ef78) WITH AND WITHOUT this PR's one-line CLI change (verified by reverting the file and re-measuring) — the PR is complexity-net-zero. Tighten via --update next cycle.", + "_rebaseline_2026_07_02_v3844_merge_burst": "1995->2003 (+8). Inherited v3.8.44 cycle drift surfaced by PR #5939: check:complexity measures 2003 on BOTH the pristine release tip (3a3d618fe) and this PR's merged HEAD — identical, so all +8 came from the 2026-07-02 merge burst into release/v3.8.44 (#5933 codex schema, #5950 OCR, #5904/#5920 combo, #6000/#6008 executor refactors, etc.) merged while the fast-gates queue was base-red (file-size #5933). PR #5939 itself was verified complexity-net-zero during its own CI cycle (DiscoveryPageClient refactored into hooks/sub-components to stay under max-lines-per-function). Tighten via --update next cycle.", "_rebaseline_2026_07_02_5798_release_green": "1982->1995 (+13). Inherited v3.8.43 cycle drift surfaced by the release-green unblock #5798 / PR #5896: check:complexity measures 1995 on BOTH the pristine release tip (0d3875a98) and this PR's HEAD — identical, so all +13 came from the 2026-07-01/02 merge burst (providers/usage/dashboard fixes merged via --admin while the fast-gates queue was base-red). This PR touches only gate scripts, docs, baselines and test files — 0 production logic. Tighten via --update next cycle.", "_rebaseline_2026_07_01_v3843_release": "1981->1982 (+1). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (it was masked behind that earlier step in the Fast Quality Gates chain). 1982 = the value measured by check:complexity on BOTH fce85136c (release tip) and 6d7060e21 (release + the 5 CI fixes) — identical, so all +1 is inherited cycle drift; the fixes touch only test files + linkify.ts safeHttpHref (cyclomatic ~4, well under the >=15 threshold, 0 new violations) + config JSON. Tighten via --update next cycle.", "_rebaseline_2026_06_28_v3840_5237_reconcile": "1980->1981 (+1). Inherited release/v3.8.40 drift surfaced while merging PR #5237 (impersonation-UA refresh) — the +1 is present on the pristine release tip (d8a392a47) WITHOUT #5237's changes, so it is #5222 (antigravity fallback-LRU retry) / #5221 (command-code) growth that merged via --admin without ratcheting complexity (the PR->release fast-gates do not run check:complexity). #5237 itself is complexity-net-zero: its only edits are a single UA constant, a regenerated golden snapshot, and baseline JSONs. Structural reduction tracked in #3501.", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 15a134b796..c014e793d3 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_07_03_review_prs_release_green": "Release-green unblock (2026-07-03, /review-prs): the quality.yml fast-gates job was base-red for EVERY PR->release from growth inherited via already-merged PRs on the release tip — no offending PR branch left to fix in-place. Prod frozen raised: ApiManagerPageClient.tsx 3017->3058, OAuthModal.tsx 969->989, cliRuntime.ts 1090->1100, webProvidersA.ts 805->809. Test frozen raised: deepseek-web.test.ts 1081->1092. Real sizes (check-file-size.mjs reported). These stay frozen (cannot grow further); structural shrink tracked under decomposition roadmap #3501; the release captain's rebaseline-at-release supersedes this note. Bundled with the #5695 quick-start test regex fix (multi-line tolerance) in the same release-green PR.", "_rebaseline_2026_07_02_5798_release_green": "Release-green unblock #5798 / PR #5896 (2026-07-02): the quality.yml fast-gates job was base-red for EVERY PR->release (whole queue failing), from growth inherited via already-merged PRs — no offending PR branch left to fix. Prod frozen raised: AddApiKeyModal.tsx 869->905, providerPageHelpers.ts 996->1021, RequestLoggerV2.tsx 1316->1553, src/sse/services/auth.ts 2403->2405, antigravity.ts 1806->1813, base.ts 1502->1536 (1533 inherited + 3 lines from this PR's own typecheck:core fix in resolveBaseUrl), advancedTools.ts 1118->1120, accountFallback.ts 1783->1790, openai-to-kiro.ts 842->853, openai-responses.ts 1035->1092, stream.ts 2710->2727; new-above-cap frozen: webProvidersA.ts 805, tokenHealthCheck.ts 830. Test frozen raised: cc-compatible-provider 1179->1217, translator-openai-to-kiro 999->1088, web-cookie-providers-new 827->845; new-above-cap: response-sanitizer.test.ts 906. These files remain frozen (cannot grow further); the release captain's rebaseline-at-release supersedes this note.", "_rebaseline_2026_06_30_5552_flat_rate_cost": "Issue #5552 own growth: src/app/api/usage/analytics/route.ts 941->942 (+1 = the `flatRateAsZero: true` cost option at the existing computeUsageRowCost chokepoint, so subscription/cookie-web providers show $0 instead of an inflated per-token estimate in analytics). The flat-rate classifier (isFlatRateProvider + the provider-id set) lives in a new leaf src/lib/usage/flatRateProviders.ts (61 LOC, 1502 (+2 = import + the single `requestCredentials = withForcedResponsesUpstream(...)` const threaded through buildUrl/buildHeaders/applyConfiguredUserAgent/ccRequestDefaults/transformRequest at the existing fetch-loop chokepoint), open-sse/executors/default.ts 876->877 (+1 = the `_omnirouteForceResponsesUpstream` short-circuit in the buildUrl `/responses` vs `/chat/completions` decision), tests/unit/executor-default-base.test.ts 1477->1523 (+46 = the new regression test that asserts a Responses-shaped MCP request routes to /responses for openai-compatible providers). The detection helpers (shouldForceResponsesUpstream/withForcedResponsesUpstream/isRecord, ~50 LOC) were EXTRACTED out of base.ts into a new leaf open-sse/executors/forceResponsesUpstream.ts (60 LOC, 1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", "cap": 800, "frozen": { + "_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.", + "open-sse/services/qoderCli.ts": 989, "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1813, @@ -182,7 +185,7 @@ "open-sse/utils/stream.ts": 2727, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1028, - "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3017, + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3058, "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, "src/app/(dashboard)/dashboard/cache/page.tsx": 845, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900, @@ -209,7 +212,7 @@ "src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974, "src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898, "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1117, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1183, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, @@ -240,14 +243,14 @@ "src/lib/usage/providerLimits.ts": 955, "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": 969, + "src/shared/components/OAuthModal.tsx": 989, "src/shared/components/RequestLoggerV2.tsx": 1553, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1662, "src/shared/constants/providers.ts": 3276, "src/shared/constants/sidebarVisibility.ts": 1198, - "src/shared/services/cliRuntime.ts": 1090, + "src/shared/services/cliRuntime.ts": 1100, "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 7913017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", - "src/lib/providers/validation/webProvidersA.ts": 805, + "src/lib/providers/validation/webProvidersA.ts": 809, "src/lib/tokenHealthCheck.ts": 830 }, "testCap": 800, @@ -281,7 +284,7 @@ "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/deepseek-web.test.ts": 1081, + "tests/unit/deepseek-web.test.ts": 1092, "tests/unit/executor-antigravity.test.ts": 942, "tests/unit/executor-codex.test.ts": 1347, "tests/unit/executor-default-base.test.ts": 1523, @@ -294,10 +297,10 @@ "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "tests/unit/oauth-providers-config.test.ts": 873, "tests/unit/perplexity-web.test.ts": 959, - "tests/unit/provider-models-route.test.ts": 1628, + "tests/unit/provider-models-route.test.ts": 1752, "tests/unit/provider-validation-specialty.test.ts": 2874, "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", - "tests/unit/providers-page-utils.test.ts": 1052, + "tests/unit/providers-page-utils.test.ts": 1109, "tests/unit/reasoning-cache.test.ts": 980, "tests/unit/route-edge-coverage.test.ts": 1234, "tests/unit/search-handler-extended.test.ts": 1124, @@ -306,14 +309,15 @@ "tests/unit/token-refresh-service.test.ts": 1353, "tests/unit/translator-friendly-test-bench.test.tsx": 848, "tests/unit/translator-helper-branches.test.ts": 870, - "tests/unit/translator-openai-responses-req.test.ts": 1097, + "tests/unit/translator-openai-responses-req.test.ts": 1172, "tests/unit/translator-openai-to-gemini.test.ts": 1579, - "tests/unit/translator-openai-to-kiro.test.ts": 1088, + "tests/unit/translator-openai-to-kiro.test.ts": 1093, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, "tests/unit/combo-config.test.ts": 881, - "tests/unit/web-cookie-providers-new.test.ts": 845, + "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", + "tests/unit/web-cookie-providers-new.test.ts": 850, "tests/unit/response-sanitizer.test.ts": 906 }, "_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.", @@ -338,6 +342,7 @@ "_rebaseline_2026_06_13_2743d_skipbreaker": "Re-baseline #2743 gap-d (testar consumer do skipProviderBreaker): combo.ts 5131→5162 (+31). Crescimento = extração do boolean inline da decisão de circuit-breaker para o predicado puro EXPORTADO shouldRecordProviderBreakerFailure() (byte-idêntico) + JSDoc, para torná-lo unit-testável sem o harness completo de combo. Shrink estrutural segue com #3501.", "_rebaseline_2026_06_13_v3825_prettier_reconcile": "Reconciliação tardia: o prettier do pre-commit reformatou 3 arquivos DEPOIS da medição de file-size dos PRs, inflando linhas além do baseline setado — OAuthModal.tsx 956→960 e providers.ts 3146→3147 (#3324), combo.ts 5162→5164 (#2743d). Bumps de reformatação automática (sem lógica nova). LIÇÃO: medir file-size pós-commit (pós-prettier), não antes.", "_rebaseline_2026_06_14_3826_release_drift": "Re-baseline release/v3.8.25 drift already documented from #3809 owner changes: ProxyRegistryManager.tsx 1072→1089, sidebarVisibility.ts 990→1006, schemas.ts 2519→2522. This PR does not touch those source files; updating the frozen values restores Fast Quality Gates on the current release branch.", + "_rebaseline_2026_07_03_5918_proxy_batch": "PR #5918 own growth: ProxyRegistryManager.tsx 1089→1117 (+28 = wiring the new batch-select/Test-All proxy management components — checkboxes, batch actions bar, health cells). Cohesive UI wiring for the batch-delete/auto-test feature; the reusable pieces already live in separate leaf components (ProxyBatchActions/ProxyCheckboxCell/ProxyHealthCell/useProxyBatchOperations). Legitimate feature growth, not a quality regression.", "_rebaseline_2026_06_14_3825_combo_stickiness": "Re-baseline #3825 (sessionless combo stickiness + reasoning-aware readiness): combo.ts 5164→5198 (+34, pós-prettier). Crescimento = deriveComboSessionKey() + effectiveSessionId threading nos sites de read/write do pin server-side. streamReadinessPolicy.ts não-frozen (sob cap). Lógica coesa no handler de combo; não-extraível.", "_rebaseline_2026_06_14_r3_3835_kiro_pricing": "PR #3835 own growth: pricing.ts 1470→1508 (+38 = missing Kiro pricing rows, claude-sonnet-4.6 etc., pure data). Also carries inherited release/v3.8.25 drift not yet frozen by prior r2 merges: RequestLoggerV2.tsx 1276→1282 (#3820 resizable log table) and combo.ts 5198→5203 (#3811 round-robin replay-response fix). This PR does not touch RequestLoggerV2/combo.ts source; updating the frozen values restores Fast Quality Gates on the current release branch.", "_rebaseline_2026_06_14_r3_3849_transient_hide": "PR #3849 own growth: providerPageHelpers.ts 939→955 (+16 = expanded JSDoc on the auto-hide policy + transient-failure guard in evaluateTestAllEntry). Cohesive helper logic; not extractable.", @@ -355,6 +360,7 @@ "_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.", "_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298.", "_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.", + "_rebaseline_2026_07_02_5899_airforce_v1_discovery": "PR #5904 own growth: provider-models-route.test.ts 1628->1752 (+124 = test-only Rule #18 regression guards for the Api Airforce /v1/v1/models discovery bug (#5899): (a) a baseUrl ending in /v1/chat/completions must probe .../v1/models not the doubled .../v1/v1/models, and the host-guard case http://v1; (b) a REDIRECT_BLOCKED on one candidate must continue to the next endpoint instead of aborting the probe loop. Both guards fail on the pre-fix code. Test-only additions cohesive with the existing provider-models discovery suite (shared seedConnection/callRoute harness); not separately extractable without duplicating the harness.", "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.", "_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.", "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index e85d042676..0dcd251ef2 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -2,7 +2,8 @@ "_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.", "metrics": { "eslintWarnings": { - "value": 4199, + "value": 4256, + "_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "4199->4256 (+57). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrue unmeasured across the cycle). 4256 = measured by `node scripts/quality/collect-metrics.mjs` on the release tip 72ee80649 during the /review-prs fix-batch round. The round's own merges (#5958 SSE-accept, #5988 deepseek-web, #6013/#5974 retry-after-json, #5975 embeddings-proxy, #5973 non-json-guard) plus the parallel-session merge burst into release/v3.8.44 account for the delta; all `any`-warn-allowed in open-sse/ + tests/. Cyclomatic is already green (2012 < baseline 2015) and needs no bump. Tighten via --require-tighten next cycle.", "_rebaseline_2026_07_02_v3843_release_close": "4158->4199 (+41). v3.8.43 release-close drift measured by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across the ~120 commits merged after the mid-cycle 4158 rebaseline — the compression T02/T05/T06/T07/T08/T10 engine families, memory typed decay, provider adds Ollama/SenseNova, ~55 SSE/translator/kiro/oauth/dashboard fixes, and the god-file decomposition wave). Trust-but-verify: measured 4199 via `npm run lint` on the release-finalize working tree INCLUDING my changes (CHANGELOG/i18n/README docs + kiro pricing data entry + the 3 base-red CODE fixes: opencode fabrication removal, resolveEffectiveKey type-widen, openai-to-claude claudeFinishEmitted flag + 4 test-alignment files + golden snapshot regen) — the code fixes NET-REMOVE lines and add no `any`/unused, and lint reported 4199 both before and after them, so all +41 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "direction": "down", "_rebaseline_2026_07_01_v3843_release": "4121->4158 (+37). v3.8.43 cycle drift surfaced by the release-green pre-flight; the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle. 4158 = the value measured by the CI Quality Ratchet on the release tip fce85136c (release PR #5609). Trust-but-verify: the fix/release-v3843-ci-reds branch touches only test files (rtk-mcp-tools de-flake, compression-studio e2e anchor, oauth-error-linkify hardening test) + src/shared/utils/linkify.ts (eslint-clean, 0 warnings) + stryker.conf.json + this baseline -> 0 new warnings, so all +37 is inherited cycle drift (any warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", @@ -114,7 +115,10 @@ "_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": 856, + "value": 861, + "_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "860->861 (+1). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight during the /review-prs fix-batch round; check:cognitive-complexity measures 861 on the release tip 72ee80649. Negligible +1 from the round's / parallel-session merge burst (cognitive-complexity does NOT run on PR->release fast-gates). Structural shrink tracked in #3501. Tighten via --update next cycle.", + "_rebaseline_2026_07_02_v3844_post_5939": "859->860 (+1). Same inherited post-3a3d618fe release drift as the complexity note of this date; this PR touches one CLI line (nullish default, no new function over the threshold) and one test file (not scanned). Tighten via --update next cycle.", + "_rebaseline_2026_07_02_v3844_merge_burst": "856->859 (+3). Inherited v3.8.44 cycle drift surfaced by PR #5939: check:cognitive-complexity measures 859 on BOTH the pristine release tip (3a3d618fe) and this PR's merged HEAD — identical, so the PR is cognitive-net-zero (its DiscoveryPageClient was refactored into hooks/sub-components during its own CI cycle). Drift from the 2026-07-02 merge burst into release/v3.8.44. Tighten via --update next cycle.", "_rebaseline_2026_07_02_5798_release_green": "845->856 (+11). Inherited v3.8.43 cycle drift surfaced by the release-green unblock #5798 / PR #5896: check:cognitive-complexity measures 856 on BOTH the pristine release tip (0d3875a98) and this PR's HEAD — identical, so the PR is cognitive-net-zero (it touches only gate scripts, docs, baselines and test files). Drift from the 2026-07-01/02 merge burst. Tighten via --update next cycle.", "direction": "down", "_rebaseline_2026_07_01_v3843_release": "842->845 (+3). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (masked behind it in the Fast Quality Gates chain). 845 = measured by check:cognitive-complexity on BOTH fce85136c and 6d7060e21 (identical) — all +3 is inherited cycle drift; the 5 CI fixes add 0 (safeHttpHref cognitive ~2, under the 15 threshold). Tighten via --update next cycle.", diff --git a/docs/README.md b/docs/README.md index df0040172e..d43de85363 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,6 +71,7 @@ Lookup material — API surface, environment variables, CLI flags, provider cata - [API_REFERENCE.md](reference/API_REFERENCE.md) — REST API endpoints and shapes. - [PROVIDER_REFERENCE.md](reference/PROVIDER_REFERENCE.md) — auto-generated provider catalog (do not edit by hand). +- [PROVIDER_PLUGIN_MANIFEST.md](reference/PROVIDER_PLUGIN_MANIFEST.md) — sidecar-safe provider plugin contract for Bifrost and CLIProxyAPI migration. - [openapi.yaml](openapi.yaml) — OpenAPI spec for the public API. - [ENVIRONMENT.md](reference/ENVIRONMENT.md) — environment variables reference. - [FEATURE_FLAGS.md](reference/FEATURE_FLAGS.md) — feature flags and their defaults. diff --git a/docs/compression/EXTENDING_COMPRESSION.md b/docs/compression/EXTENDING_COMPRESSION.md index 9aea461674..7b33f5b37d 100644 --- a/docs/compression/EXTENDING_COMPRESSION.md +++ b/docs/compression/EXTENDING_COMPRESSION.md @@ -1,7 +1,7 @@ --- title: "Extending the Compression Pipeline" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.44 +lastUpdated: 2026-07-02 --- # Extending the Compression Pipeline @@ -512,6 +512,62 @@ To drive it from config, set `mode: "stacked"` and provide the step array under --- +## Upstream Sync Policy + +OmniRoute's compression engines credit several upstream projects in the README +("inspired by RTK, Caveman, LLMLingua-2, Troglodita"). A common contributor +question is: **when upstream RTK adds a new tool filter or Caveman adds a rule +pack, how does that reach OmniRoute?** This section is the authoritative answer. + +### Vendored copies vs. independent implementations + +| Engine | Relationship to upstream | Location | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **RTK** | **Independent reimplementation** (inspired-by, not a copy) | `open-sse/services/compression/engines/rtk/` | +| **Caveman** | **Independent reimplementation** (inspired-by) | `open-sse/services/compression/engines/cavemanAdapter.ts` | +| **Headroom** | Mostly internal; only the `gcf/` codec is **genuinely vendored** from `gcf-typescript` (MIT, SPDX-marked, generic profile only) | `open-sse/services/compression/engines/headroom/gcf/` | +| **LLMLingua-2 / Troglodita** | Inspired-by (drive the `llmlingua` + `session-dedup` engines) | `open-sse/services/compression/engines/llmlingua/`, `session-dedup` | + +Key point: **RTK and Caveman are clean-room TypeScript implementations of the +_ideas_ (filter rules, rule packs), not vendored source trees.** There is no +upstream copy to `git pull` from — which is exactly why the README says +"inspired by" rather than "bundled". + +### How upstream improvements are merged + +There is **no automated upstream-release tracking and no `compression-sync` +label** — by design. Because the engines are reimplementations, an upstream RTK +filter or Caveman rule pack is not merged as code; it is **re-expressed as a new +rule/filter in OmniRoute's own format** (see +[COMPRESSION_RULES_FORMAT.md](./COMPRESSION_RULES_FORMAT.md)) and lands ad-hoc via +a normal PR. The extension points above (custom engine, language pack, RTK filter) +are the sanctioned way to contribute one. + +Recent examples of exactly this flow: + +- RTK filters for Gradle & `dotnet` build output (v3.8.42) +- RTK filters for kubectl / docker-build / composer / gh (#2824) +- Caveman Indonesian language pack (#3975), plus German / French / Japanese / Chinese packs + +### Headroom (input-compression proxy) + +Headroom is **fully internal** — a pinned vendored `gcf` codec snapshot plus +OmniRoute's own `smartcrusher` / `toon` / `tabular` layers. There is no live +upstream to track beyond the vendored copy; updates to `gcf` are refreshed +manually when the codec changes and re-validated against the compression budget +gate (`check:compression-budget`). + +### Proposing an upstream-inspired improvement + +1. **Don't vendor** — re-express the upstream rule/filter in OmniRoute's format. +2. Add it via the matching extension point below (language pack, RTK filter, or + custom engine). +3. Reference the upstream project in the PR description (attribution), not by + copying its license-bearing source. +4. Include tests and confirm the `check:compression-budget` gate still passes. + +--- + ## Best Practices ### Engine Development diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index dd578c992d..66b2f59acd 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -1,13 +1,13 @@ --- title: "Embedded Services" -description: "Reference for 9Router and CLIProxyAPI" +description: "Reference for 9Router, CLIProxyAPI, Mux, and Bifrost" --- # Embedded Services -> **Version:** v3.8.4 -> **Last updated:** 2026-06-28 -> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI). +> **Version:** v3.8.44 +> **Last updated:** 2026-07-03 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost). Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and exposes as first-class routing targets. Unlike external providers (which are reached over the internet @@ -32,18 +32,20 @@ via API keys), embedded services run on the same machine as OmniRoute and commun ### Why embedded services? -Two services are embedded as of v3.8.4: +Four services are embedded as of v3.8.44: -| Service | npm package | Default port | Purpose | -| --------------- | ---------------------------------------------- | :----------: | ---------------------------------------------------------------------------------------------------- | -| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | -| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | +| Service | npm package | Default port | Purpose | +| --------------- | ----------------------------------------------- | :----------: | ------------------------------------------------------------------------------------------------------------------ | +| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | +| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | +| **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | +| **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) | -Both follow the same supervisory model: +All four follow the same supervisory model: - OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) - OmniRoute spawns and monitors them as child processes -- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime +- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime (where applicable) - All management routes (`/api/services/*`) are **LOCAL_ONLY** — accessible only from loopback (hard rule #17) ### Key decisions (from design plan) @@ -54,7 +56,7 @@ Both follow the same supervisory model: | Installation mechanism | `npm install {package}` via `execFile` (no shell interpolation) | | Consumption mode | Provider registered as `9router/{sub}/{model}` in routing engine | | API key management | OmniRoute generates, encrypts at-rest (AES-256-GCM), and injects via env | -| Dashboard location | `/dashboard/providers/services` (two tabs) | +| Dashboard location | `/dashboard/providers/services` (three tabs) | | Auto-start | Toggle per service, default OFF | --- @@ -64,12 +66,13 @@ Both follow the same supervisory model: ``` ┌────────────────────────────────────────────────────────────────────┐ │ Layer 1 — UI │ -│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router) │ +│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router | Mux)│ │ Logs live (SSE), Start/Stop/Restart/Update, Settings, Install │ │ │ │ src/app/(dashboard)/dashboard/providers/services/ │ │ ├── page.tsx Shell + tab routing by ?tab= │ -│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab│ +│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab,│ +│ │ MuxServiceTab │ │ └── components/ ServiceStatusCard, ServiceLifecycleButtons,│ │ ServiceLogsPanel, ApiKeyCard, ... │ └──────────────────────┬─────────────────────────────────────────────┘ @@ -81,6 +84,8 @@ Both follow the same supervisory model: │ rotate-key|status|auto-start|logs} │ │ /api/services/cliproxy/{install|start|stop|restart|update| │ │ status|auto-start|logs} │ +│ /api/services/mux/{install|start|stop|restart|update| │ +│ status|auto-start|logs} │ │ /dashboard/providers/services/9router/embed/[...path] │ │ (reverse HTTP + WebSocket proxy → 9Router upstream) │ │ │ @@ -106,7 +111,8 @@ Both follow the same supervisory model: │ modelSync.ts Periodic GET /v1/models → service_models table │ │ ringBuffer.ts Circular log buffer (5 MB per service) │ │ healthCheck.ts Polling HTTP health probe │ -│ installers/ ninerouter.ts, cliproxy.ts (installer adapters)│ +│ installers/ ninerouter.ts, cliproxy.ts, mux.ts │ +│ (installer adapters) │ └──────────────────────┬─────────────────────────────────────────────┘ │ OpenAI-compatible HTTP (loopback) ┌──────────────────────▼─────────────────────────────────────────────┐ @@ -123,6 +129,10 @@ Both follow the same supervisory model: │ open-sse/config/providerRegistry.ts │ │ Models stored as "9router/{sub}/{model}" (prefixed). │ │ Synced every 5 min by modelSync.ts. │ +│ │ +│ Mux is lifecycle-managed ONLY (Layers 1-3) — it is an agent- │ +│ orchestration daemon, not an LLM proxy, so it has no Layer 4 │ +│ executor/provider entry and is never a routing target. │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -139,6 +149,7 @@ Both follow the same supervisory model: | `src/lib/services/healthCheck.ts` | HTTP health probe (configurable interval) | | `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router | | `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | +| `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux | | `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | | `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | | `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | @@ -434,12 +445,56 @@ config) and `status` includes fewer fields. | `GET` | `/api/services/cliproxy/status` | Live + DB status (no `apiKeyMasked`) | | `POST` | `/api/services/cliproxy/auto-start` | Toggle auto-start | -The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for both -services using the `[name]` dynamic segment. +The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for all +four services using the `[name]` dynamic segment. --- -### 4.3 Reverse proxy (9Router dashboard embed) +### 4.3 Mux endpoints (7 routes) + +Mux has the same endpoint shape as CLIProxyAPI — no `rotate-key` route in the API +surface (the bearer token is generated the same way as 9Router's via +`getOrCreateApiKey("mux")` and injected via the `MUX_SERVER_AUTH_TOKEN` env var, but +there is no dedicated rotation endpoint yet). Mux is lifecycle-managed only: unlike +9Router, it has no Layer 4 executor and is never registered as a routing provider. + +| Method | Path | Description | +| ------ | -------------------------------- | ------------------------------------- | +| `POST` | `/api/services/mux/install` | Install Mux from npm (`npm i mux`) | +| `POST` | `/api/services/mux/start` | Start Mux (`mux server`) | +| `POST` | `/api/services/mux/stop` | Stop Mux | +| `POST` | `/api/services/mux/restart` | Restart Mux | +| `POST` | `/api/services/mux/update` | Update to newer npm version | +| `GET` | `/api/services/mux/status` | Live + DB status | +| `POST` | `/api/services/mux/auto-start` | Toggle auto-start | + +--- + +### 4.4 Bifrost endpoints (7 routes) + +Bifrost is a Go AI-gateway relay backend (`@maximhq/bifrost`). It uses the same +endpoint shape as CLIProxyAPI (no `rotate-key` — Bifrost manages its own provider +keys in `config.json` under its `-app-dir`). + +| Method | Path | Description | +| ------ | ---------------------------------- | ------------------------------------------------------ | +| `POST` | `/api/services/bifrost/install` | Install Bifrost from npm (`@maximhq/bifrost`) | +| `POST` | `/api/services/bifrost/start` | Start Bifrost on port 8080 (default) | +| `POST` | `/api/services/bifrost/stop` | Stop Bifrost | +| `POST` | `/api/services/bifrost/restart` | Restart Bifrost | +| `POST` | `/api/services/bifrost/update` | Update to newer version | +| `GET` | `/api/services/bifrost/status` | Live + DB status | +| `POST` | `/api/services/bifrost/auto-start` | Toggle auto-start | +| `GET` | `/api/services/bifrost/logs` | SSE log tail (via shared `[name]/logs` dynamic route) | + +**Routing wiring:** When `BIFROST_BASE_URL` is unset and the supervised Bifrost +instance is running, `getBifrostRoutingConfig()` (in `routingBackend.ts`) automatically +uses `http://127.0.0.1:{port}` as the relay base URL. Explicit `BIFROST_BASE_URL` env +always takes precedence. + +--- + +### 4.4 Reverse proxy (9Router dashboard embed) The dashboard embeds the 9Router web UI inside an iframe via an internal reverse proxy at: @@ -486,14 +541,20 @@ matrix. ### API key injection -9Router requires an API key for its own HTTP endpoints. OmniRoute: +9Router and Mux require an API key/bearer token for their own HTTP endpoints. +OmniRoute: 1. Generates a key via `crypto.randomBytes(32).toString("base64url")` with a - service-specific prefix (`nr_` for 9Router). + service-specific prefix (`nr_` for 9Router, `mx_` for Mux). 2. Encrypts it at-rest using AES-256-GCM (same cipher used for provider credentials). -3. Decrypts and injects it as `NINEROUTER_API_KEY` environment variable at spawn time. +3. Decrypts and injects it as an environment variable at spawn time — + `NINEROUTER_API_KEY` for 9Router, `MUX_SERVER_AUTH_TOKEN` for Mux (never a CLI + flag, so the token never appears in `ps`/process listings). 4. Never returns the plaintext key in any HTTP response. +CLIProxyAPI does not require an injected key (it authenticates via the host's +existing CLI config). + ### SSRF defense The reverse HTTP proxy (`/dashboard/.../embed/[...path]`) is hardcoded to forward diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 595fc5acd6..b4c82b1fd6 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 31584a5e8a..8de9ab05bb 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 31584a5e8a..8de9ab05bb 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index ecb7c8e47d..617accb78e 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index b33dcd9dcb..e62fbf84b6 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 0525ff7242..80fbdba782 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index b5b2b63797..9e14f4b4c3 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 7bd26eab9b..69e027a78a 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index de9a2f73cd..ffbe0a483e 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 4d44cfd43a..f871b94115 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 6d251e12b6..9e2fcaafd4 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index b456a14e03..4c7a87e6c2 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 89d09bc81d..2e4bd19326 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 022cc03f83..df76512c74 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 7e1094da11..a18e470447 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 2813e7ac16..8d2aed4eab 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index bcfeaade5e..fcf2ed56fa 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 800b252e70..c94f8141bd 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index be80401d52..b85edc2f77 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 35281cc458..15379d4c50 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index edde061766..04f2941515 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index c20b9f5284..0716d956ae 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 3a49f71458..7310fa3d05 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index aa3ecfc80f..5120498b4a 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 3fed9c0458..b70c00c980 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index b2b275f538..3ea2df224e 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 2603b1352c..544c063285 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 794882a1bc..50d165299b 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 442c9577ad..85c2e5f0a9 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index f2bcc29907..559cc49cd9 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index b691acf9bb..f64d625fa0 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 8cd03360d7..cb14cfd613 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index f96f4493fe..4db8d7f643 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 87e0bd9ccb..12fe054a6a 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 6ae74a28db..5d4d5d2b0f 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 893556f19e..e914ae4e52 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index e39fb48283..de9f3a3504 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 9a17657000..28140682bc 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index e405d76c37..889b71d4ff 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index ae3daff0c5..63fcd348f0 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 30f8a24b68..c12a4802f3 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/i18n/zh-TW/CHANGELOG.md b/docs/i18n/zh-TW/CHANGELOG.md index d8dcc93c70..b06956abe3 100644 --- a/docs/i18n/zh-TW/CHANGELOG.md +++ b/docs/i18n/zh-TW/CHANGELOG.md @@ -6,6 +6,24 @@ ## [3.8.31] — 2026-06-20 +## [3.8.44] — TBD + +### ✨ New Features + +_TBD_ + +### 🔧 Bug Fixes + +_TBD_ + +### 📝 Maintenance + +- **chore(release):** release-pipeline hardening — `check:test-masking` (vs `origin/main`) is now a HARD gate in the release-green pre-flight (`validate-release-green.mjs`), so a non-allowlisted net-assert reduction surfaces locally instead of in a ~40-min CI layer on the release PR; plus two reconciliation helpers — `npm run release:contributors` (reproducible `### 🙌 Contributors` table via a parenthetical-group parser) and `npm run release:uncovered` (lists commits with no CHANGELOG bullet). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926) — thanks @diegosouzapw) + +- **chore(ci):** the `check:pr-evidence` FAIL report now tells you that editing the PR body does not re-run the gate (`ci.yml` ignores the `edited` event) — push a commit to re-validate. ([#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944) — thanks @diegosouzapw) + +--- + ## [3.8.43] — 2026-07-02 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 764ee2cb37..aab8d0169d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.43 + version: 3.8.44 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -3444,6 +3444,276 @@ paths: "400": description: Invalid request body + /api/services/mux/install: + post: + tags: [Embedded Services] + summary: Install Mux from npm + description: >- + Installs the `mux` npm package (coder/mux — local agent-orchestration + daemon) under DATA_DIR/services/mux/. **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/mux/start: + post: + tags: [Embedded Services] + summary: Start Mux + description: >- + Spawns `mux server --host 127.0.0.1 --port `. Idempotent if + already running. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: Mux is not installed + "503": + description: Start failed + + /api/services/mux/stop: + post: + tags: [Embedded Services] + summary: Stop Mux + description: >- + Gracefully stops Mux. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/restart: + post: + tags: [Embedded Services] + summary: Restart Mux + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/update: + post: + tags: [Embedded Services] + summary: Update Mux to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/mux/status: + get: + tags: [Embedded Services] + summary: Get Mux status + description: >- + Returns live supervisor state and DB metadata. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/auto-start: + post: + tags: [Embedded Services] + summary: Toggle Mux auto-start + description: >- + When enabled, Mux starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/bifrost/install: + post: + tags: [Embedded Services] + summary: Install Bifrost + description: >- + Installs the `@maximhq/bifrost` npm package under DATA_DIR/services/bifrost/. + The package downloads the Go binary on first run. Accepts an optional `version` + field (semver or `latest`). **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Installation result + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + installPath: + type: string + durationMs: + type: number + + /api/services/bifrost/start: + post: + tags: [Embedded Services] + summary: Start Bifrost + description: Starts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service status after start + "409": + description: Bifrost is not installed + + /api/services/bifrost/stop: + post: + tags: [Embedded Services] + summary: Stop Bifrost + description: Stops the supervised Bifrost process. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service status after stop + + /api/services/bifrost/restart: + post: + tags: [Embedded Services] + summary: Restart Bifrost + description: Restarts the supervised Bifrost process. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service status after restart + "409": + description: Bifrost is not installed + + /api/services/bifrost/update: + post: + tags: [Embedded Services] + summary: Update Bifrost + description: >- + Updates Bifrost to the latest npm version. Stops the running process, + installs the new version, and restarts if it was previously running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Update result + + /api/services/bifrost/status: + get: + tags: [Embedded Services] + summary: Get Bifrost status + description: Returns live and DB status for the supervised Bifrost service. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Bifrost service status + + /api/services/bifrost/auto-start: + post: + tags: [Embedded Services] + summary: Toggle Bifrost auto-start + description: >- + When enabled, Bifrost starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "204": + description: Auto-start flag updated + "400": + description: Invalid request body + /api/services/{name}/logs: get: tags: [Embedded Services] @@ -5264,6 +5534,214 @@ paths: $ref: "#/components/responses/InternalError" "503": description: Generator module not available + /api/v1/ocr: + post: + tags: + - Images + summary: Document OCR + description: >- + Mistral OCR–compatible document OCR endpoint. Accepts a JSON body + referencing a document/image and returns extracted text. Success + responses carry the `X-OmniRoute-*` cost-telemetry headers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + model: + type: string + document: + type: object + responses: + "200": + description: OCR result with extracted text. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/audio/translations: + post: + tags: + - Audio + summary: Translate audio to English + description: >- + OpenAI Whisper–compatible audio translation (multipart/form-data). + Unlike `/api/v1/audio/transcriptions`, output is always English + regardless of the source language. Success responses carry the + `X-OmniRoute-*` cost-telemetry headers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + model: + type: string + responses: + "200": + description: English translation of the audio. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/providers/suggested-models: + get: + tags: + - Providers + summary: Suggested media models + description: >- + Read-only server-side proxy to the public HuggingFace Hub models search + API, used by the dashboard to suggest models for a media provider kind + without exposing an HF token client-side. Never accepts or returns + credentials. + parameters: + - name: type + in: query + schema: + type: string + description: Media kind to search for (e.g. `image`, `audio`, `video`). + responses: + "200": + description: List of suggested HuggingFace Hub models. + "500": + $ref: "#/components/responses/InternalError" + /api/v1/provider-plugin-manifest: + get: + tags: + - Providers + summary: Provider plugin manifest + description: Returns the manifest describing installed provider plugins. + responses: + "200": + description: Provider plugin manifest. + "500": + $ref: "#/components/responses/InternalError" + /api/keys/{id}/devices: + get: + tags: + - API Keys + summary: List devices for an API key + description: >- + Lists the distinct devices (masked IP + User-Agent fingerprints) + tracked for an API key by the in-memory device tracker. IPs are masked + before storage; the route never sees the raw client IP. + x-internal: true + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Distinct devices seen for the API key. + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /api/settings/purge-usage-history: + post: + tags: + - Settings + summary: Purge usage history + description: Dashboard-only. Purges stored usage-history records. + x-internal: true + responses: + "200": + description: Usage history purged. + "401": + $ref: "#/components/responses/Unauthorized" + /api/oauth/codex/import-token: + post: + tags: + - OAuth + summary: Import a Codex connection from a bare access token + description: >- + Dashboard-only. Creates a Codex (ChatGPT/OpenAI) connection from a raw + access token with no refresh token (authType `access_token`). + x-internal: true + responses: + "200": + description: Connection imported. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /api/cli-tools/crush-settings: + get: + tags: + - CLI Tools + summary: Read Crush CLI OmniRoute config + description: Local-only. Reads the OmniRoute provider block in Crush's config. + x-internal: true + responses: + "200": + description: Current Crush config state. + post: + tags: + - CLI Tools + summary: Write Crush CLI OmniRoute config + description: Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config. + x-internal: true + responses: + "200": + description: Crush config updated. + delete: + tags: + - CLI Tools + summary: Remove OmniRoute from Crush CLI config + description: Local-only. Removes the OmniRoute provider block from Crush's config. + x-internal: true + responses: + "200": + description: Crush config entry removed. + /api/cli-tools/codewhale-settings: + get: + tags: + - CLI Tools + summary: Read CodeWhale CLI OmniRoute config + description: >- + Local-only. Reads the OmniRoute config block from + `~/.codewhale/config.toml` (with `~/.deepseek/config.toml` legacy + fallback). + x-internal: true + responses: + "200": + description: Current CodeWhale config state. + post: + tags: + - CLI Tools + summary: Write CodeWhale CLI OmniRoute config + description: Local-only. Writes the OmniRoute config block in CodeWhale TOML format. + x-internal: true + responses: + "200": + description: CodeWhale config updated. + delete: + tags: + - CLI Tools + summary: Remove OmniRoute from CodeWhale CLI config + description: Local-only. Removes the OmniRoute config block from CodeWhale's config. + x-internal: true + responses: + "200": + description: CodeWhale config entry removed. components: securitySchemes: diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 9e8003697a..02bba19fa9 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -18,6 +18,7 @@ Complete reference for all OmniRoute API endpoints. - [Embeddings](#embeddings) - [Image Generation](#image-generation) - [List Models](#list-models) +- [Provider Plugin Manifest](#provider-plugin-manifest) - [Compatibility Endpoints](#compatibility-endpoints) - [Files API](#files-api) - [Batches API](#batches-api) @@ -178,6 +179,22 @@ Selecting this id (e.g. in a Claude Code config that always attaches a `thinking --- +## Provider Plugin Manifest + +```bash +GET /api/v1/provider-plugin-manifest +``` + +Returns the JSON-safe provider plugin manifest used by Bifrost, CLIProxyAPI, and +future sidecar routers. The response is generated from the TypeScript provider +registry and intentionally excludes OAuth client secrets, runtime environment +resolution, executor functions, request headers, and account data. + +Use this endpoint when a sidecar runs out-of-process and cannot import +`open-sse/config/providerPluginManifestRegistry.ts` directly. + +--- + ## Compatibility Endpoints | Method | Path | Format | diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 15d1162af1..42529429d2 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -12,7 +12,7 @@ OmniRoute integrates with three categories of CLI tools spread across three dedi | Page | Route | Concept | Count | | -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ | -| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 19 | +| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 20 | | **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 6 | | **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry | @@ -90,7 +90,7 @@ Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages --- -## 1. CLI Code's Catalog (19 tools) +## 1. CLI Code's Catalog (20 tools) Tools that support custom base URL and appear in `/dashboard/cli-code`: @@ -107,6 +107,7 @@ Tools that support custom base URL and appear in `/dashboard/cli-code`: | forge | ForgeCode | Antinomy HQ | full | custom | true | | jcode | jcode | 1jehuang (OSS) | full | custom | false | | deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false | +| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false | | opencode | OpenCode | Anomaly (ex-SST) | full | guide | true | | droid | Factory Droid | Factory AI | partial | guide | false | | copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | @@ -198,7 +199,8 @@ New tools with `configType: "custom"` have dedicated settings API routes: | ------------------------------------------- | ------------------------------ | | `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) | | `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) | -| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL) | +| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) | +| `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) | | `POST /api/cli-tools/smelt-settings` | Smelt | | `POST /api/cli-tools/pi-settings` | Pi coding agent | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b307c1b1fb..e45e4f1897 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -117,6 +117,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | Variable | Default | Source File | Description | | ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | | `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | @@ -264,6 +265,8 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. | | `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. | | `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and non-dashboard browser-origin validation (for example `/v1/chatgpt-web/image/`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. | +| `OMNIROUTE_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, OmniRoute derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. | +| `OMNIROUTE_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `OMNIROUTE_PROVIDER_MANIFEST_URL` is set. | | `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. | | `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. | | `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. | @@ -353,6 +356,8 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | | `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | | `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | +| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | +| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | ### CLI Profile Auto-Sync @@ -475,6 +480,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). | | `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. | | `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. | +| `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). | | `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. | | `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. | | `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. | @@ -618,6 +624,8 @@ REQUEST_TIMEOUT_MS (global override) | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | | `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | | `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | +| `FIRECRAWL_BASE_URL` | `https://api.firecrawl.dev` | Point the Firecrawl web-fetch executor at a self-hosted instance (API key optional off-cloud). | +| `FIRECRAWL_TIMEOUT_MS` | `30000` | Per-request timeout for the Firecrawl web-fetch executor. | | `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. | | `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | @@ -806,6 +814,7 @@ Automatic model pricing data synchronization from external sources. | `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | | `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | | `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | +| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). | | `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | `ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients @@ -822,6 +831,11 @@ Anthropic-compatible provider instead. | `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | | `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | | `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. | +| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. | +| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). | +| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. | +| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. | +| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). | | `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. | | `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | | `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | @@ -1005,6 +1019,10 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). | | `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. | | `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. | +| `FREE_PROXY_WEBSHARE_ENABLED` | `true` | `src/lib/freeProxyProviders/webshare.ts` | Enable the Webshare proxy pool source. Set to `false` to disable; also requires `FREE_PROXY_WEBSHARE_API_KEY` to be set. | +| `FREE_PROXY_WEBSHARE_API_KEY` | _(none)_ | `src/lib/freeProxyProviders/webshare.ts` | Webshare account API token (`Authorization: Token `). Required — the provider stays disabled without it. | +| `FREE_PROXY_WEBSHARE_API_URL` | `https://proxy.webshare.io/api/v2/proxy/list/` | `src/lib/freeProxyProviders/webshare.ts` | Webshare proxy list API URL override. | +| `FREE_PROXY_WEBSHARE_MAX` | `500` | `src/lib/freeProxyProviders/webshare.ts` | Maximum proxies imported per Webshare sync. | | `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. | | `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). | | `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. | @@ -1038,6 +1056,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | | `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | | `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | +| `BIFROST_PORT` | `8080` | `src/lib/services/bootstrap.ts` | Port the supervised Bifrost embedded service binds to (`127.0.0.1:`) when OmniRoute manages the Bifrost sidecar lifecycle. Defaults to `8080`. | | `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. | | `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | | `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. | diff --git a/docs/reference/PROVIDER_PLUGIN_MANIFEST.md b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md new file mode 100644 index 0000000000..9f91dc39ed --- /dev/null +++ b/docs/reference/PROVIDER_PLUGIN_MANIFEST.md @@ -0,0 +1,81 @@ +--- +title: "Provider Plugin Manifest" +version: 3.8.42 +lastUpdated: 2026-07-01 +--- + +# Provider Plugin Manifest + +`open-sse/config/providerPluginManifest.ts` defines the JSON-safe provider +plugin contract. `open-sse/config/providerPluginManifestRegistry.ts` binds that +contract to the current provider registry for sidecars such as Bifrost, +CLIProxyAPI, or a future Go/Rust router. The TypeScript registry remains the +source of truth, but sidecars can consume the manifest without importing +executor code, OAuth defaults, headers, or process environment state. + +The same manifest is available over HTTP at +`GET /api/v1/provider-plugin-manifest` for sidecars that run out-of-process. + +OmniRoute advertises that URL to Bifrost and CLIProxyAPI via the +`X-OmniRoute-Provider-Manifest-Url` request header. Set +`OMNIROUTE_PROVIDER_MANIFEST_URL` when the sidecar needs a public or container +network URL instead of the local request origin. + +## Goal + +Move provider metadata toward a plugin contract so the hot request path can +eventually be owned by a lower-latency sidecar while OmniRoute keeps the +TypeScript route as the policy gate and fallback. The manifest is additive: it +does not change request routing by itself. + +## Contract + +The manifest contains: + +- provider id and alias +- upstream format and executor name +- auth type, auth header, and optional auth prefix +- static endpoint metadata +- sidecar eligibility and explicit reasons when a provider should stay on TS +- JSON-safe model metadata such as context length, vision/reasoning flags, and + unsupported params +- capability tags including `apikey`, `oauth`, `custom-executor`, + `passthrough-models`, `responses`, and `sidecar-candidate` + +The manifest intentionally excludes: + +- OAuth client secrets and default secret values +- runtime environment resolution +- request headers and public credential helpers +- dynamic URL builders +- executor functions +- session pool internals + +## Sidecar Use + +Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not +as an unconditional routing decision. The first import target should be +API-key, static-endpoint providers using the default executor. Providers with +custom web executors, OAuth/session flows, dynamic URL builders, or pool config +stay on the TypeScript fallback path until a sidecar implements equivalent +behavior and telemetry proves parity. + +Suggested migration phases: + +1. Generate and validate the provider plugin manifest from the TS registry. +2. Teach Bifrost or CLIProxyAPI to import the manifest for API-key/static + providers. +3. Route eligible providers through the sidecar behind `OMNIROUTE_RELAY_BACKEND` + while keeping TS fallback enabled. +4. Promote providers only when success rate, p99 latency, streaming behavior, + and unsupported-param handling match the TS path. +5. Add sidecar-native plugins for custom executors one provider family at a + time. + +## Why Not Embed Providers Directly In Next + +The Next frontend should not own provider execution. It should call the API +boundary. The backend can then decide whether to use the TypeScript executor, +Bifrost, CLIProxyAPI, or a future native sidecar. This keeps request signing, +allowlist checks, DB policy, and fallback behavior centralized before any +sidecar handoff. diff --git a/docs/reference/RELAY_BACKEND_STRATEGY.md b/docs/reference/RELAY_BACKEND_STRATEGY.md index 6db4942e74..69e38f28a0 100644 --- a/docs/reference/RELAY_BACKEND_STRATEGY.md +++ b/docs/reference/RELAY_BACKEND_STRATEGY.md @@ -75,3 +75,10 @@ For sustained high RPM/RPS and strict success SLO: - `BIFROST_ENABLED=1` - Keep API keys, allowlist, sanitizer, and rate-limit checks enabled in route handlers (they always run before downstream forwarding). - Export fallback metrics from your reverse proxy and request logs so sidecar outages are visible within one minute. + +## Provider plugin contract + +Sidecars should import provider metadata through the JSON-safe provider plugin +manifest instead of depending on TypeScript executor internals. See +[Provider Plugin Manifest](./PROVIDER_PLUGIN_MANIFEST.md) for the sidecar +eligibility contract and migration phases. diff --git a/docs/security/CORS.md b/docs/security/CORS.md new file mode 100644 index 0000000000..557f7fa610 --- /dev/null +++ b/docs/security/CORS.md @@ -0,0 +1,162 @@ +--- +title: CORS Configuration & Security +--- + +# CORS Configuration & Security + +OmniRoute controls which **browser origins** may read cross-origin responses +from a single, centralized allowlist. The model is **fail-closed by default**: +no origin is allowed until you opt one in. This page documents how the allowlist +resolves, what `CORS_ALLOW_ALL=true` actually exposes (and, importantly, what it +does **not**), how to configure dev vs production safely, and the runtime warning +the dashboard shows when a wildcard is live. + +**Source of truth:** `src/server/cors/origins.ts` (`resolveAllowedOrigin`, +`applyCorsHeaders`, `getCorsStatus`). The allowlist is applied once, in the +middleware (`src/server/authz/pipeline.ts`) — per-route handlers do not set +`Access-Control-Allow-Origin` themselves. + +## How an origin is resolved + +For each request the middleware computes the `Access-Control-Allow-Origin` value +in this order: + +1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's + `Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin` + so caches stay correct. +2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash + stripped) and matched against the **merged allowlist**: + - env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and + - the runtime **`corsOrigins`** setting (Dashboard → Security → _CORS Allowed + Origins_), injected via `setRuntimeAllowedOrigins()` from + `src/lib/config/runtimeSettings.ts`. +3. No match → **no `Access-Control-Allow-Origin` header is emitted**. The browser + blocks the cross-origin read. This is the intended fail-closed default. + +| Env var | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `CORS_ALLOWED_ORIGINS` | CSV of exact origins to allow (recommended). | +| `CORS_ALLOW_ALL` | `true`/`1` → echo any origin (wildcard). Dev only. | +| `CORS_ORIGIN` | Legacy. `*` behaves like `CORS_ALLOW_ALL`; a single value is added to the allowlist. | + +## Threat model — what `CORS_ALLOW_ALL=true` really exposes + +The generic OWASP warning ("wildcard CORS = any site can call your API") is worth +taking seriously, but OmniRoute's exposure is **narrower than the generic case**, +because of one concrete implementation fact: + +> **The central `applyCorsHeaders()` never emits +> `Access-Control-Allow-Credentials`.** A browser will not expose a _credentialed_ +> (cookie-bearing) cross-origin response unless the server sends +> `Access-Control-Allow-Credentials: true`. OmniRoute's shared CORS path never +> does. + +What that means per surface, even with `CORS_ALLOW_ALL=true`: + +| Surface | Auth mechanism | Effect of wildcard CORS | +| ----------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dashboard / MANAGEMENT `/api/*` | Cookie session | Origin is echoed, but **without `Allow-Credentials`** the browser **blocks** the credentialed read. A malicious cross-origin site **cannot read** your authenticated dashboard responses, and the session cookie is not exposed. | +| Client API `/v1/*`, `/v1beta/*` | Bearer / `x-api-key` header | Already permissive **by design** (`relaxForTokenAuth`): browsers never auto-attach `Authorization`/`x-api-key`, so an attacker's page cannot supply your key. `CORS_ALLOW_ALL` does not widen this. | +| Public read-only (`/api/health`, …) | None | Non-sensitive; wildcard is harmless. | + +So the **residual** exposure of `CORS_ALLOW_ALL=true` is limited to: (a) +non-credentialed cross-origin **reads** of already-unauthenticated data, and (b) +letting CORS **preflight pass** on management routes — which still require auth +that a cross-origin page cannot provide. It is **not** a session-hijack or +credential-theft vector on the shared CORS path. + +### One genuine exception — `/api/v1/agents/` + +The Cloud-Agent routes (`/api/v1/agents/{health,credentials,tasks,tasks/[id]}`) set +their **own** CORS headers +(`src/lib/cloudAgent/api.ts`, `getCloudAgentCorsHeaders`) and **do** emit +`Access-Control-Allow-Origin: |*` together with +`Access-Control-Allow-Credentials: true`. This is the single surface where +origin-echo and credentials coexist, and it is **independent of +`CORS_ALLOW_ALL`**. These routes are management-authenticated +(`requireManagementAuth`); operators who expose the dashboard off-host should be +aware that this is the one place a cross-origin credentialed read is permitted by +the response headers. Tightening it to an explicit allowlist is tracked +separately from this CORS guidance. + +## Production checklist + +- **Never set `CORS_ALLOW_ALL=true` in production.** Leave it unset. +- Set an **explicit** origin list — either the env var or the Security-tab field: + + ```bash + CORS_ALLOWED_ORIGINS="https://app.example.com, https://admin.example.com" + ``` + +- If OmniRoute runs behind a reverse proxy / tunnel (nginx, Caddy, Cloudflare + Tunnel, Tailscale), CORS is **not** your only control — the loopback route + guard still protects spawn-capable routes (see + [ROUTE_GUARD_TIERS](./ROUTE_GUARD_TIERS.md)). Do not forge + `X-Forwarded-For: 127.0.0.1` to "fix" a 403; that re-opens the RCE class the + route guard closes. +- Confirm the runtime state: the dashboard shows a **persistent amber banner** + under Dashboard → Security → Authorization Inventory whenever + `CORS_ALLOW_ALL=true` is live, and `/api/settings/authz-inventory` returns a + `cors: { allowAll, allowedOrigins }` envelope monitoring tools can poll. + +## Development convenience — allow specific local origins + +You rarely need the wildcard even in dev. Allow just the dev servers you use: + +```bash +# Vite (5173) + Next.js (3000) dev servers calling a local OmniRoute +CORS_ALLOWED_ORIGINS="http://localhost:5173, http://localhost:3000" +``` + +Origins are matched case-insensitively with the trailing slash ignored, so +`http://localhost:3000` and `http://localhost:3000/` are equivalent. The same CSV +can be set at runtime in **Dashboard → Security → CORS Allowed Origins** without a +restart. + +## API keys vs cookie sessions + +- **Bearer / `x-api-key` (the `/v1/*` inference surface):** browsers never attach + these automatically. CORS is not a meaningful barrier here — the API key is the + barrier — which is why that surface is intentionally permissive so browser and + Electron clients can read responses they are already entitled to. +- **Cookie session (the dashboard):** protected by the fail-closed default **and** + by the absence of `Access-Control-Allow-Credentials` on the shared path. Keep + management/dashboard origins out of any permissive config; they must stay exactly + fail-closed. + +## Example: reverse proxy in front of OmniRoute + +CORS is enforced by OmniRoute itself, so the proxy generally should **not** add or +rewrite `Access-Control-*` headers (double headers break browsers). Terminate TLS +and forward — let OmniRoute answer preflight: + +```nginx +# nginx — forward to OmniRoute; do NOT inject Access-Control-* here +location / { + proxy_pass http://127.0.0.1:20128; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + # Do NOT set X-Forwarded-For to 127.0.0.1 — it defeats the loopback route guard. +} +``` + +Set the allowed browser origins in OmniRoute (`CORS_ALLOWED_ORIGINS` or the +Security tab), not in the proxy. + +## Source files + +| Concern | File | +| ----------------------------------------------- | -------------------------------------------------------------------- | +| Allowlist resolution + `getCorsStatus()` | `src/server/cors/origins.ts` | +| Middleware application (single source of truth) | `src/server/authz/pipeline.ts` | +| Settings → runtime origin injection | `src/lib/config/runtimeSettings.ts` | +| Runtime status for the dashboard | `src/app/api/settings/authz-inventory/route.ts` | +| Dashboard warning banner | `src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx` | +| CORS Allowed Origins field | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` | +| Cloud-Agent per-route CORS (the exception) | `src/lib/cloudAgent/api.ts` | + +## See also + +- [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for + spawn-capable routes (a separate, complementary control). +- [Authorization Guide](../architecture/AUTHZ_GUIDE.md) — the full auth pipeline. diff --git a/electron/package-lock.json b/electron/package-lock.json index a2073848e4..4bd0d087ca 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.43", + "version": "3.8.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.43", + "version": "3.8.44", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index 0563f35ae5..6cca7ee884 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.43", + "version": "3.8.44", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/next.config.mjs b/next.config.mjs index 24bbc30de5..415f5b986e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -94,6 +94,13 @@ function readTimeoutMs(...values) { /** @type {import('next').NextConfig} */ const nextConfig = { + // Opt-in subpath deployment behind a reverse proxy (e.g. nginx/Caddy serving + // OmniRoute under https://host/omniroute/). Empty by default so root-path + // deployments are unaffected. Next.js strips this prefix from `pathname` + // before route matching, so authz classification (classifyRoute/isLocalOnlyPath) + // keeps operating on un-prefixed paths — see src/server/authz/pipeline.ts for + // the two redirect call sites that re-add it via `request.nextUrl.basePath`. + basePath: process.env.OMNIROUTE_BASE_PATH || "", distDir, // Turbopack config: redirect native modules to stubs at build time turbopack: { @@ -207,6 +214,13 @@ const nextConfig = { "tough-cookie", "@ngrok/ngrok", "@huggingface/transformers", + // copilot-m365-web.ts imports 'ws' as a client-side WebSocket. When bundled, + // ws cannot resolve its 'bufferutil' native addon (frame masking) and throws + // TypeError: b.mask is not a function on the first outgoing frame, causing + // every chat request to time out at the stream-readiness watchdog. (#6062) + "ws", + "bufferutil", + "utf-8-validate", "child_process", "fs", "path", diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index b281a94e35..cbeaa789e5 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -3,6 +3,7 @@ * * Defines providers that support audio endpoints: * - /v1/audio/transcriptions (Whisper API) + * - /v1/audio/translations (Whisper translate-to-English API) * - /v1/audio/speech (TTS API) */ @@ -160,6 +161,30 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { }, }; +/** + * Providers that expose an OpenAI-Whisper-compatible /audio/translations + * endpoint (translate-to-English). This is a narrower surface than + * transcription: only Whisper-family models support it, and there is no + * `language` input — output is always English regardless of source audio. + */ +export const AUDIO_TRANSLATION_PROVIDERS: Record = { + openai: { + id: "openai", + baseUrl: "https://api.openai.com/v1/audio/translations", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "whisper-1", name: "Whisper 1" }], + }, + + groq: { + id: "groq", + baseUrl: "https://api.groq.com/openai/v1/audio/translations", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "whisper-large-v3", name: "Whisper Large v3" }], + }, +}; + export const AUDIO_SPEECH_PROVIDERS: Record = { vertex: { id: "vertex", @@ -403,6 +428,13 @@ export function getTranscriptionProvider(providerId: string): AudioProvider | nu return AUDIO_TRANSCRIPTION_PROVIDERS[providerId] || null; } +/** + * Get translation provider config by ID + */ +export function getTranslationProvider(providerId: string): AudioProvider | null { + return AUDIO_TRANSLATION_PROVIDERS[providerId] || null; +} + /** * Get speech provider config by ID */ @@ -480,6 +512,13 @@ export function parseSpeechModel(modelStr: string | null, dynamicProviders?: Aud return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS, dynamicProviders); } +export function parseTranslationModel( + modelStr: string | null, + dynamicProviders?: AudioProvider[] +) { + return parseAudioModel(modelStr, AUDIO_TRANSLATION_PROVIDERS, dynamicProviders); +} + /** * Get all audio models as a flat list */ diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index d7ba0777f5..16ffcd68ab 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -93,6 +93,17 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + "vercel-ai-gateway": { + id: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, + { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, + ], + }, + upstage: { id: "upstage", baseUrl: "https://api.upstage.ai/v1/embeddings", diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index bc83b37c84..330a37c730 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -175,6 +175,20 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "2048x2048"], }, + "vercel-ai-gateway": { + id: "vercel-ai-gateway", + alias: "vag", + baseUrl: "https://ai-gateway.vercel.sh/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "gpt-image-1", name: "GPT Image 1" }, + { id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 Pro" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + together: { id: "together", baseUrl: "https://api.together.xyz/v1/images/generations", @@ -549,6 +563,35 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], }, + // NVIDIA NIM image generation (FLUX models). Distinct from the NVIDIA *chat* entry + // (open-sse/config/providers/registry/nvidia/index.ts, host integrate.api.nvidia.com, + // OpenAI-compatible) — image generation lives on ai.api.nvidia.com/v1/genai/ + // with a native NIM body per model, so it gets a dedicated `nvidia-nim` format/handler + // (handleNvidiaNimImageGeneration) rather than reusing the OpenAI image path. + // Ported from upstream 9router#1195. + nvidia: { + id: "nvidia", + baseUrl: "https://ai.api.nvidia.com/v1/genai", + authType: "apikey", + authHeader: "bearer", + format: "nvidia-nim", + models: [ + { id: "black-forest-labs/flux.1-dev", name: "FLUX.1 Dev", inputModalities: ["text", "image"] }, + { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, + { + id: "black-forest-labs/flux.1-kontext-dev", + name: "FLUX.1 Kontext Dev (Edit)", + inputModalities: ["text", "image"], + }, + { + id: "black-forest-labs/flux.2-klein-4b", + name: "FLUX.2 Klein 4B", + inputModalities: ["text", "image"], + }, + ], + supportedSizes: ["1024x1024", "768x1344", "512x512"], + }, + // SenseNova (商汤日日新) Text-to-Image on the free Token Plan. OpenAI-compatible // `/v1/images/generations`, so the generic OpenAI image handler routes it — same // SenseNova api-key/connection as the chat provider. (9router#2233) @@ -561,6 +604,27 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "sensenova-u1-fast", name: "SenseNova U1 Fast" }], supportedSizes: ["1024x1024"], }, + + // HuggingFace Hub Inference API text-to-image task. Returns raw image bytes + // (not JSON), so it uses a dedicated "huggingface-image" format handled by + // handleHuggingFaceImageGeneration. Same base URL convention as the HF + // STT/TTS entries in audioRegistry.ts. Model list is deliberately small — + // the dashboard's "suggested models" chip row (GET + // /api/v1/providers/suggested-models) surfaces additional HF Hub models + // beyond this seed list. + huggingface: { + id: "huggingface", + baseUrl: "https://api-inference.huggingface.co/models", + authType: "apikey", + authHeader: "bearer", + format: "huggingface-image", + models: [ + { id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev (HF)" }, + { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (HF)" }, + { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "Stable Diffusion XL (HF)" }, + ], + supportedSizes: ["1024x1024"], + }, }; /** diff --git a/open-sse/config/mediaServiceKinds.ts b/open-sse/config/mediaServiceKinds.ts index 95e831ce40..22a692c971 100644 --- a/open-sse/config/mediaServiceKinds.ts +++ b/open-sse/config/mediaServiceKinds.ts @@ -17,14 +17,12 @@ * still declared explicitly via `serviceKinds` on the provider entry; callers * union the two sources. */ -import { - AUDIO_TRANSCRIPTION_PROVIDERS, - AUDIO_SPEECH_PROVIDERS, -} from "./audioRegistry.ts"; +import { AUDIO_TRANSCRIPTION_PROVIDERS, AUDIO_SPEECH_PROVIDERS } from "./audioRegistry.ts"; import { VIDEO_PROVIDERS } from "./videoRegistry.ts"; import { MUSIC_PROVIDERS } from "./musicRegistry.ts"; import { IMAGE_PROVIDERS } from "./imageRegistry.ts"; import { EMBEDDING_PROVIDERS } from "./embeddingRegistry.ts"; +import { OCR_PROVIDERS } from "./ocrRegistry.ts"; /** Media kinds whose provider membership is defined by a backend registry. */ export const MEDIA_KIND_REGISTRIES = { @@ -34,6 +32,7 @@ export const MEDIA_KIND_REGISTRIES = { music: MUSIC_PROVIDERS, image: IMAGE_PROVIDERS, embedding: EMBEDDING_PROVIDERS, + ocr: OCR_PROVIDERS, } as const satisfies Record>; export type RegistryMediaKind = keyof typeof MEDIA_KIND_REGISTRIES; diff --git a/open-sse/config/moderationRegistry.ts b/open-sse/config/moderationRegistry.ts index 4a3f621998..c63d9e17b4 100644 --- a/open-sse/config/moderationRegistry.ts +++ b/open-sse/config/moderationRegistry.ts @@ -5,7 +5,25 @@ * Follows OpenAI's moderation API format. */ -export const MODERATION_PROVIDERS = { +export interface ModerationModel { + id: string; + name: string; +} + +export interface ModerationProvider { + id: string; + baseUrl: string; + authType: string; + authHeader: string; + models: ModerationModel[]; +} + +export interface ParsedModerationModel { + provider: string | null; + model: string | null; +} + +export const MODERATION_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/moderations", @@ -16,22 +34,29 @@ export const MODERATION_PROVIDERS = { { id: "text-moderation-latest", name: "Text Moderation Latest" }, ], }, + mistral: { + id: "mistral", + baseUrl: "https://api.mistral.ai/v1/moderations", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "mistral-moderation-latest", name: "Mistral Moderation" }], + }, }; /** - * Get moderation provider config by ID + * Get moderation provider config by ID. */ -export function getModerationProvider(providerId) { +export function getModerationProvider(providerId: string): ModerationProvider | null { return MODERATION_PROVIDERS[providerId] || null; } /** - * Parse moderation model string + * Parse a moderation model string. */ -export function parseModerationModel(modelStr) { +export function parseModerationModel(modelStr: string | null | undefined): ParsedModerationModel { if (!modelStr) return { provider: null, model: null }; - for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { + for (const providerId of Object.keys(MODERATION_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; } @@ -47,10 +72,10 @@ export function parseModerationModel(modelStr) { } /** - * Get all moderation models as a flat list + * Get all moderation models as a flat list. */ -export function getAllModerationModels() { - const models = []; +export function getAllModerationModels(): Array<{ id: string; name: string; provider: string }> { + const models: Array<{ id: string; name: string; provider: string }> = []; for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { for (const model of config.models) { models.push({ diff --git a/open-sse/config/ocrRegistry.ts b/open-sse/config/ocrRegistry.ts new file mode 100644 index 0000000000..fdf47d44f1 --- /dev/null +++ b/open-sse/config/ocrRegistry.ts @@ -0,0 +1,82 @@ +/** + * OCR Provider Registry + * + * Defines providers that support the /v1/ocr endpoint. + * Follows Mistral's OCR API format. + */ + +export interface OcrModel { + id: string; + name: string; +} + +export interface OcrProvider { + id: string; + baseUrl: string; + authType: string; + authHeader: string; + models: OcrModel[]; +} + +export interface ParsedOcrModel { + provider: string | null; + model: string | null; +} + +export const OCR_PROVIDERS: Record = { + mistral: { + id: "mistral", + baseUrl: "https://api.mistral.ai/v1/ocr", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }], + }, +}; + +/** + * Get OCR provider config by ID. + */ +export function getOcrProvider(providerId: string): OcrProvider | null { + return OCR_PROVIDERS[providerId] || null; +} + +/** + * Parse an OCR model string. + * + * Accepts either a "provider/model" prefixed string or a bare model id that + * matches one of the registered OCR models. + */ +export function parseOcrModel(modelStr: string | null | undefined): ParsedOcrModel { + if (!modelStr) return { provider: null, model: null }; + + for (const providerId of Object.keys(OCR_PROVIDERS)) { + if (modelStr.startsWith(providerId + "/")) { + return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + } + } + + for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) { + if (config.models.some((m) => m.id === modelStr)) { + return { provider: providerId, model: modelStr }; + } + } + + return { provider: null, model: modelStr }; +} + +/** + * Get all OCR models as a flat list. + */ +export function getAllOcrModels(): Array<{ id: string; name: string; provider: string }> { + const models: Array<{ id: string; name: string; provider: string }> = []; + for (const [providerId, config] of Object.entries(OCR_PROVIDERS)) { + for (const model of config.models) { + models.push({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + }); + } + } + return models; +} diff --git a/open-sse/config/providerPluginManifest.ts b/open-sse/config/providerPluginManifest.ts new file mode 100644 index 0000000000..b0a773e17c --- /dev/null +++ b/open-sse/config/providerPluginManifest.ts @@ -0,0 +1,186 @@ +import type { RegistryEntry, RegistryModel } from "./providers/shared.ts"; + +export type ProviderPluginCapability = + | "apikey" + | "custom-executor" + | "oauth" + | "passthrough-models" + | "responses" + | "sidecar-candidate"; + +export interface ProviderPluginModel { + id: string; + name: string; + contextLength?: number; + maxOutputTokens?: number; + toolCalling?: boolean; + supportsReasoning?: boolean; + supportsVision?: boolean; + unsupportedParams?: readonly string[]; + targetFormat?: string; +} + +export interface ProviderPluginManifestEntry { + id: string; + alias?: string; + format: string; + executor: string; + auth: { + type: string; + header: string; + prefix?: string; + }; + endpoints: { + baseUrl?: string; + baseUrls?: string[]; + responsesBaseUrl?: string; + chatPath?: string; + modelsUrl?: string; + }; + capabilities: ProviderPluginCapability[]; + passthroughModels: boolean; + defaultContextLength?: number; + timeoutMs?: number; + models: ProviderPluginModel[]; + sidecar: { + eligible: boolean; + reasons: string[]; + }; +} + +export interface ProviderPluginManifest { + schemaVersion: 1; + generatedFrom: "open-sse/config/providers"; + providers: ProviderPluginManifestEntry[]; +} + +const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]); + +function compactObject>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ) as Partial; +} + +function mapModel(model: RegistryModel): ProviderPluginModel { + return compactObject({ + id: model.id, + name: model.name, + contextLength: model.contextLength, + maxOutputTokens: model.maxOutputTokens, + toolCalling: model.toolCalling, + supportsReasoning: model.supportsReasoning, + supportsVision: model.supportsVision, + unsupportedParams: model.unsupportedParams, + targetFormat: model.targetFormat, + }) as ProviderPluginModel; +} + +function sidecarEligibility(entry: RegistryEntry): { eligible: boolean; reasons: string[] } { + const reasons: string[] = []; + + if (!SIDECAR_COMPATIBLE_EXECUTORS.has(entry.executor)) { + reasons.push(`custom executor: ${entry.executor}`); + } + if (entry.authType !== "apikey" && entry.authType !== "optional" && entry.authType !== "none") { + reasons.push(`auth type requires TS handling: ${entry.authType}`); + } + if (!entry.baseUrl && !entry.baseUrls?.length && !entry.responsesBaseUrl) { + reasons.push("no static upstream endpoint"); + } + if (typeof entry.urlBuilder === "function") { + reasons.push("dynamic URL builder"); + } + if (entry.oauth) { + reasons.push("oauth metadata"); + } + if (entry.poolConfig) { + reasons.push("session pool config"); + } + + return { + eligible: reasons.length === 0, + reasons, + }; +} + +function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPluginCapability[] { + const capabilities = new Set(); + + if (entry.authType === "apikey" || entry.authType === "optional") { + capabilities.add("apikey"); + } + if (entry.authType === "oauth" || entry.oauth) { + capabilities.add("oauth"); + } + if (entry.responsesBaseUrl) { + capabilities.add("responses"); + } + if (entry.passthroughModels) { + capabilities.add("passthrough-models"); + } + if (entry.executor !== "default") { + capabilities.add("custom-executor"); + } + if (eligible) { + capabilities.add("sidecar-candidate"); + } + + return [...capabilities].sort(); +} + +export function createProviderPluginManifestEntry( + entry: RegistryEntry, +): ProviderPluginManifestEntry { + const sidecar = sidecarEligibility(entry); + + return { + id: entry.id, + ...(entry.alias ? { alias: entry.alias } : {}), + format: entry.format, + executor: entry.executor, + auth: compactObject({ + type: entry.authType, + header: entry.authHeader, + prefix: entry.authPrefix, + }) as ProviderPluginManifestEntry["auth"], + endpoints: compactObject({ + baseUrl: entry.baseUrl, + baseUrls: entry.baseUrls, + responsesBaseUrl: entry.responsesBaseUrl, + chatPath: entry.chatPath, + modelsUrl: entry.modelsUrl, + }) as ProviderPluginManifestEntry["endpoints"], + capabilities: capabilitiesFor(entry, sidecar.eligible), + passthroughModels: entry.passthroughModels === true, + ...(typeof entry.defaultContextLength === "number" + ? { defaultContextLength: entry.defaultContextLength } + : {}), + ...(typeof entry.timeoutMs === "number" ? { timeoutMs: entry.timeoutMs } : {}), + models: (entry.models ?? []).map(mapModel), + sidecar, + }; +} + +export function generateProviderPluginManifestFromRegistry( + registry: Record, +): ProviderPluginManifest { + return { + schemaVersion: 1, + generatedFrom: "open-sse/config/providers", + providers: Object.values(registry) + .map(createProviderPluginManifestEntry) + .sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +export function getProviderPluginManifestEntryFromRegistry( + registry: Record, + provider: string, +): ProviderPluginManifestEntry | null { + const entry = + registry[provider] || + Object.values(registry).find((candidate) => candidate.alias === provider); + + return entry ? createProviderPluginManifestEntry(entry) : null; +} diff --git a/open-sse/config/providerPluginManifestRegistry.ts b/open-sse/config/providerPluginManifestRegistry.ts new file mode 100644 index 0000000000..37b92ba4fb --- /dev/null +++ b/open-sse/config/providerPluginManifestRegistry.ts @@ -0,0 +1,31 @@ +import { REGISTRY } from "./providers/index.ts"; +import { + generateProviderPluginManifestFromRegistry, + getProviderPluginManifestEntryFromRegistry, + type ProviderPluginManifestEntry, +} from "./providerPluginManifest.ts"; + +export function generateProviderPluginManifest() { + return generateProviderPluginManifestFromRegistry(REGISTRY); +} + +export function getProviderPluginManifestEntry(provider: string) { + return getProviderPluginManifestEntryFromRegistry(REGISTRY, provider); +} + +export function getProviderPluginManifestEntryForModel( + model: string | undefined, +): ProviderPluginManifestEntry | null { + if (!model) return null; + + const providerPrefix = model.includes("/") ? model.split("/", 1)[0] : ""; + if (providerPrefix) { + const prefixed = getProviderPluginManifestEntry(providerPrefix); + if (prefixed) return prefixed; + } + + const manifest = generateProviderPluginManifest(); + return manifest.providers.find((provider) => + provider.models.some((candidate) => candidate.id === model), + ) ?? null; +} diff --git a/open-sse/config/providerPluginManifestUrl.ts b/open-sse/config/providerPluginManifestUrl.ts new file mode 100644 index 0000000000..c896770f30 --- /dev/null +++ b/open-sse/config/providerPluginManifestUrl.ts @@ -0,0 +1,26 @@ +export const PROVIDER_PLUGIN_MANIFEST_HEADER = "X-OmniRoute-Provider-Manifest-Url"; +export const PROVIDER_PLUGIN_MANIFEST_PATH = "/api/v1/provider-plugin-manifest"; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + +export function resolveProviderPluginManifestUrl(origin?: string | null): string { + const configured = process.env.OMNIROUTE_PROVIDER_MANIFEST_URL?.trim(); + if (configured) return configured; + + if (origin) { + return `${trimTrailingSlash(origin)}${PROVIDER_PLUGIN_MANIFEST_PATH}`; + } + + const host = process.env.HOST || "127.0.0.1"; + const port = process.env.PORT || process.env.DASHBOARD_PORT || process.env.API_PORT || "20128"; + const protocol = process.env.OMNIROUTE_PUBLIC_PROTOCOL || "http"; + return `${protocol}://${host}:${port}${PROVIDER_PLUGIN_MANIFEST_PATH}`; +} + +export function getProviderPluginManifestHeader(origin?: string | null): Record { + return { + [PROVIDER_PLUGIN_MANIFEST_HEADER]: resolveProviderPluginManifestUrl(origin), + }; +} diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ac9847e86d..9239a5b7d2 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -41,6 +41,8 @@ import { yiProvider } from "./registry/yi/index.ts"; import { deepseekProvider } from "./registry/deepseek/index.ts"; import { deepseek_webProvider } from "./registry/deepseek/web/index.ts"; import { dgridProvider } from "./registry/dgrid/index.ts"; +import { baiProvider } from "./registry/bai/index.ts"; +import { qiniuProvider } from "./registry/qiniu/index.ts"; import { kimi_coding_apikeyProvider } from "./registry/kimi/coding-apikey/index.ts"; import { kimi_codingProvider } from "./registry/kimi/coding/index.ts"; import { kimiProvider } from "./registry/kimi/index.ts"; @@ -49,6 +51,9 @@ import { groqProvider } from "./registry/groq/index.ts"; import { inference_netProvider } from "./registry/inference-net/index.ts"; import { llm7Provider } from "./registry/llm7/index.ts"; import { cerebrasProvider } from "./registry/cerebras/index.ts"; +import { charmHyperProvider } from "./registry/charm-hyper/index.ts"; +import { nubeProvider } from "./registry/nube/index.ts"; +import { clinepassProvider } from "./registry/clinepass/index.ts"; import { sparkdeskProvider } from "./registry/sparkdesk/index.ts"; import { nlpcloudProvider } from "./registry/nlpcloud/index.ts"; import { nvidiaProvider } from "./registry/nvidia/index.ts"; @@ -78,6 +83,7 @@ import { leonardoProvider } from "./registry/leonardo/index.ts"; import { grok_webProvider } from "./registry/grok-web/index.ts"; import { kieProvider } from "./registry/kie/index.ts"; import { monsterapiProvider } from "./registry/monsterapi/index.ts"; +import { modelscopeProvider } from "./registry/modelscope/index.ts"; import { sensenovaProvider } from "./registry/sensenova/index.ts"; import { hyperbolicProvider } from "./registry/hyperbolic/index.ts"; import { lambda_aiProvider } from "./registry/lambda-ai/index.ts"; @@ -141,6 +147,7 @@ import { kilo_gatewayProvider } from "./registry/kilo-gateway/index.ts"; import { bailian_coding_planProvider } from "./registry/bailian-coding-plan/index.ts"; import { gigachatProvider } from "./registry/gigachat/index.ts"; import { devin_cliProvider } from "./registry/devin-cli/index.ts"; +import { auggieProvider } from "./registry/auggie/index.ts"; import { chutesProvider } from "./registry/chutes/index.ts"; import { factoryProvider } from "./registry/factory/index.ts"; import { databricksProvider } from "./registry/databricks/index.ts"; @@ -171,6 +178,8 @@ import { grok_cliProvider } from "./registry/grok-cli/index.ts"; import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; import { pioneerProvider } from "./registry/pioneer/index.ts"; import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts"; +import { sumopodProvider } from "./registry/sumopod/index.ts"; +import { x5labProvider } from "./registry/x5lab/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -212,6 +221,8 @@ export const REGISTRY: Record = { deepseek: deepseekProvider, "deepseek-web": deepseek_webProvider, dgrid: dgridProvider, + bai: baiProvider, + qiniu: qiniuProvider, "kimi-coding-apikey": kimi_coding_apikeyProvider, "kimi-coding": kimi_codingProvider, kimi: kimiProvider, @@ -220,6 +231,9 @@ export const REGISTRY: Record = { "inference-net": inference_netProvider, llm7: llm7Provider, cerebras: cerebrasProvider, + "charm-hyper": charmHyperProvider, + nube: nubeProvider, + clinepass: clinepassProvider, sparkdesk: sparkdeskProvider, nlpcloud: nlpcloudProvider, nvidia: nvidiaProvider, @@ -249,6 +263,7 @@ export const REGISTRY: Record = { "grok-web": grok_webProvider, kie: kieProvider, monsterapi: monsterapiProvider, + modelscope: modelscopeProvider, sensenova: sensenovaProvider, hyperbolic: hyperbolicProvider, "lambda-ai": lambda_aiProvider, @@ -311,6 +326,7 @@ export const REGISTRY: Record = { "bailian-coding-plan": bailian_coding_planProvider, gigachat: gigachatProvider, "devin-cli": devin_cliProvider, + auggie: auggieProvider, chutes: chutesProvider, factory: factoryProvider, databricks: databricksProvider, @@ -344,4 +360,6 @@ export const REGISTRY: Record = { "codebuddy-cn": codebuddy_cnProvider, pioneer: pioneerProvider, "zenmux-free": zenmux_freeProvider, + sumopod: sumopodProvider, + x5lab: x5labProvider, }; diff --git a/open-sse/config/providers/registry/anthropic/index.ts b/open-sse/config/providers/registry/anthropic/index.ts index b990c8fb23..4ea19e8381 100644 --- a/open-sse/config/providers/registry/anthropic/index.ts +++ b/open-sse/config/providers/registry/anthropic/index.ts @@ -38,6 +38,13 @@ export const anthropicProvider: RegistryEntry = { }, { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + contextLength: 1048576, + // Sonnet 5 rejects non-default sampling params with a 400 (adaptive-only). + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.6" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, diff --git a/open-sse/config/providers/registry/auggie/index.ts b/open-sse/config/providers/registry/auggie/index.ts new file mode 100644 index 0000000000..ee346426a0 --- /dev/null +++ b/open-sse/config/providers/registry/auggie/index.ts @@ -0,0 +1,38 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// Augment / Auggie CLI — local no-auth provider. The executor spawns the +// user's local `auggie` binary (auth handled entirely by `auggie login`); +// OmniRoute never stores credentials for this connection. +export const auggieProvider: RegistryEntry = { + id: "auggie", + alias: "aug", + format: "openai", + executor: "auggie", + baseUrl: "auggie://cli/stdio", + authType: "none", + authHeader: "none", + defaultContextLength: 200000, + models: [ + // Claude + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { + id: "claude-sonnet-4.6-thinking", + name: "Claude Sonnet 4.6 Thinking", + contextLength: 200000, + }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + // Gemini + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", contextLength: 1000000 }, + { id: "gemini-3.0-flash", name: "Gemini 3 Flash", contextLength: 1000000 }, + // GPT-5.x + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + // Kimi + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 131000 }, + // Prism (Augment's in-house model) + { id: "prism", name: "Augment Prism", contextLength: 200000 }, + ], +}; diff --git a/open-sse/config/providers/registry/bai/index.ts b/open-sse/config/providers/registry/bai/index.ts new file mode 100644 index 0000000000..574362693c --- /dev/null +++ b/open-sse/config/providers/registry/bai/index.ts @@ -0,0 +1,14 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const baiProvider: RegistryEntry = { + id: "bai", + alias: "bai", + format: "openai", + executor: "default", + baseUrl: "https://api.b.ai/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://api.b.ai/v1/models", + models: [], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/blackbox/index.ts b/open-sse/config/providers/registry/blackbox/index.ts index e5fba3d9f8..87d0ce26d7 100644 --- a/open-sse/config/providers/registry/blackbox/index.ts +++ b/open-sse/config/providers/registry/blackbox/index.ts @@ -12,6 +12,7 @@ export const blackboxProvider: RegistryEntry = { models: [ { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, { id: "gpt-5.5", name: "GPT-5.5" }, { id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, diff --git a/open-sse/config/providers/registry/charm-hyper/index.ts b/open-sse/config/providers/registry/charm-hyper/index.ts new file mode 100644 index 0000000000..dfbf994891 --- /dev/null +++ b/open-sse/config/providers/registry/charm-hyper/index.ts @@ -0,0 +1,14 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const charmHyperProvider: RegistryEntry = { + id: "charm-hyper", + alias: "charm-hyper", + format: "openai", + executor: "default", + baseUrl: "https://hyper.charm.land/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://hyper.charm.land/v1/models", + models: [{ id: "hyper/auto", name: "Charm Hyper Auto" }], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/claude/index.ts b/open-sse/config/providers/registry/claude/index.ts index e5e181a985..19b2c5a509 100644 --- a/open-sse/config/providers/registry/claude/index.ts +++ b/open-sse/config/providers/registry/claude/index.ts @@ -65,6 +65,18 @@ export const claudeProvider: RegistryEntry = { contextLength: 200000, maxOutputTokens: 64000, }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + contextLength: 1000000, + maxOutputTokens: 128000, + // Sonnet 5 is the first Sonnet-tier model to support xhigh effort — do NOT copy + // the `supportsXHighEffort: false` from the older claude-sonnet-4-6/4-5 entries. + supportsXHighEffort: true, + // Sonnet 5 rejects non-default temperature/top_p/top_k with a 400 (adaptive-only; + // reasoning steered by output_config.effort). Mirrors the Opus/Fable entries. + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet", diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts new file mode 100644 index 0000000000..3ea7dbcb3a --- /dev/null +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -0,0 +1,42 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// ClinePass — Cline's $9.99/mo BYOK API-key gateway (https://cline.bot). Distinct +// from the OAuth `cline` provider: same host (api.cline.bot) but a plain Bearer +// API key and the `cline-pass/*` model namespace. Responses are wrapped in a +// {success, data} envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. +export const clinepassProvider: RegistryEntry = { + id: "clinepass", + alias: "clinepass", + format: "openai", + executor: "default", + baseUrl: "https://api.cline.bot/api/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + extraHeaders: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + models: [ + { id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" }, + { id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" }, + { id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" }, + { + id: "cline-pass/deepseek-v4-pro", + name: "DeepSeek V4 Pro (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { + id: "cline-pass/deepseek-v4-flash", + name: "DeepSeek V4 Flash (ClinePass)", + supportsReasoning: true, + maxOutputTokens: 50000, + }, + { id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" }, + { id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" }, + { id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)", supportsVision: true }, + { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" }, + { id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index af6e11153c..463e103ba6 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -14,13 +14,13 @@ export const grok_cliProvider: RegistryEntry = { { id: "grok-build", name: "Grok Build", - contextLength: 128000, + contextLength: 256000, unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], }, { id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", - contextLength: 128000, + contextLength: 200000, unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], }, ], diff --git a/open-sse/config/providers/registry/kimi/web/index.ts b/open-sse/config/providers/registry/kimi/web/index.ts index c86d3ce426..1bcd615615 100644 --- a/open-sse/config/providers/registry/kimi/web/index.ts +++ b/open-sse/config/providers/registry/kimi/web/index.ts @@ -14,8 +14,14 @@ export const kimi_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ - { id: "kimi-default", name: "Kimi Default" }, - { id: "kimi-k2.6", name: "Kimi K2.6 (Thinking)" }, - { id: "kimi-128k", name: "Kimi 128K (Long Context)" }, + // Model ids are the `key` field from www.kimi.com's + // `/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels` response. + // Agent / Agent-Swarm variants (`k2d6-agent`, `k2d6-agent-ultra`) are + // intentionally NOT exposed — they need a different scenario + // (`SCENARIO_OK_COMPUTER`) plus `kimiPlusId` / `agentMode` fields, which + // the executor does not yet shape. Use `kimi-coding` (api.kimi.com) for + // agentic flows. + { id: "k2d6", name: "K2.6 Instant" }, + { id: "k2d6-thinking", name: "K2.6 Thinking", supportsReasoning: true }, ], }; diff --git a/open-sse/config/providers/registry/modelscope/index.ts b/open-sse/config/providers/registry/modelscope/index.ts new file mode 100644 index 0000000000..f82b7cbe72 --- /dev/null +++ b/open-sse/config/providers/registry/modelscope/index.ts @@ -0,0 +1,24 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// ModelScope (Alibaba 魔搭) — OpenAI-compatible API-Inference, ported from upstream +// 9router PR #1764 (@tn5052). The upstream PR hardcoded `https://api-inference.modelscope.ai/...` +// (`.ai` TLD) and a static 5-model list. Both were dropped here after verification: +// +// - baseUrl: ModelScope's own API-Inference docs (modelscope.cn/docs/model-service/API-Inference) +// and third-party integration guides (e.g. Alibaba Cloud Model Studio compatibility docs) +// consistently confirm the production domain is `api-inference.modelscope.cn` — the `.cn` TLD, +// not `.ai`. Using the unverified `.ai` domain would have shipped a broken provider. +// - models: passthrough + empty static seed instead of copying the PR's 5-model snapshot, since +// ModelScope hosts a large and fast-moving open-model catalog — `modelsUrl` keeps the list live. +export const modelscopeProvider: RegistryEntry = { + id: "modelscope", + alias: "ms", + format: "openai", + executor: "default", + baseUrl: "https://api-inference.modelscope.cn/v1/chat/completions", + modelsUrl: "https://api-inference.modelscope.cn/v1/models", + authType: "apikey", + authHeader: "bearer", + passthroughModels: true, + models: [], +}; diff --git a/open-sse/config/providers/registry/nube/index.ts b/open-sse/config/providers/registry/nube/index.ts new file mode 100644 index 0000000000..f0e25b56b9 --- /dev/null +++ b/open-sse/config/providers/registry/nube/index.ts @@ -0,0 +1,17 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const nubeProvider: RegistryEntry = { + id: "nube", + alias: "nube", + format: "openai", + executor: "default", + baseUrl: "https://ai.nube.sh/api/v1/chat/completions", + modelsUrl: "https://ai.nube.sh/api/v1/models", + authType: "apikey", + authHeader: "bearer", + // Nube.sh is an OpenAI-compatible LiteLLM gateway (BYOK). Its live catalog is only + // reachable with a valid key (/api/v1/models returns 401 unauthenticated), so we ship + // no hardcoded model IDs and rely on passthrough + live enumeration via modelsUrl. + passthroughModels: true, + models: [], +}; diff --git a/open-sse/config/providers/registry/qiniu/index.ts b/open-sse/config/providers/registry/qiniu/index.ts new file mode 100644 index 0000000000..1bbf5021d8 --- /dev/null +++ b/open-sse/config/providers/registry/qiniu/index.ts @@ -0,0 +1,15 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const qiniuProvider: RegistryEntry = { + id: "qiniu", + alias: "qiniu", + format: "openai", + executor: "default", + baseUrl: "https://api.qnaigc.com/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://api.qnaigc.com/v1/models", + defaultContextLength: 128000, + models: [], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/qoder/index.ts b/open-sse/config/providers/registry/qoder/index.ts index e0b5721848..844cf5a945 100644 --- a/open-sse/config/providers/registry/qoder/index.ts +++ b/open-sse/config/providers/registry/qoder/index.ts @@ -18,6 +18,8 @@ export const qoderProvider: RegistryEntry = { }, models: [ { id: "qoder-rome-30ba3b", name: "Qoder ROME" }, + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "minimax-m3", name: "MiniMax M3", supportsVision: true }, { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, { id: "qwen3-max", name: "Qwen3 Max" }, { id: "qwen3-vl-plus", name: "Qwen3 Vision Plus", supportsVision: true }, diff --git a/open-sse/config/providers/registry/sumopod/index.ts b/open-sse/config/providers/registry/sumopod/index.ts new file mode 100644 index 0000000000..1a5414e879 --- /dev/null +++ b/open-sse/config/providers/registry/sumopod/index.ts @@ -0,0 +1,15 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const sumopodProvider: RegistryEntry = { + id: "sumopod", + alias: "sumopod", + format: "openai", + executor: "default", + baseUrl: "https://ai.sumopod.com/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://ai.sumopod.com/v1/models", + defaultContextLength: 128000, + models: [], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/theoldllm/index.ts b/open-sse/config/providers/registry/theoldllm/index.ts index 1b1c7691cb..a22d9901ae 100644 --- a/open-sse/config/providers/registry/theoldllm/index.ts +++ b/open-sse/config/providers/registry/theoldllm/index.ts @@ -11,15 +11,41 @@ export const theoldllmProvider: RegistryEntry = { authType: "none", authHeader: "none", defaultContextLength: 200000, + // Catalog seed. `passthroughModels: true` means live /api/chatgpt discovery is + // authoritative; this list is the curated display/fallback set. The upstream IDs + // (GPT_5_*, gemini_*, CLAUDE_4_*, openrouter_*, etc.) mirror the site's free + // "chatgpt" tier and MUST match `CHATGPT_UPSTREAM_MODELS` in the executor so they + // route unchanged. Legacy alias IDs (GPT_4o, claude_opus_4, …) are kept for + // backward compatibility with saved model preferences (mapped in the executor). models: [ + // ── Current free tier (refreshed for #5181) ── { id: "GPT_5_4", name: "GPT-5.4 (The Old LLM 🆓)", contextLength: 400000 }, + { id: "GPT_5_3", name: "GPT-5.3 (The Old LLM 🆓)", contextLength: 400000 }, + { id: "GPT_5_2", name: "GPT-5.2 (The Old LLM 🆓)", contextLength: 400000 }, + { id: "GPT_5_1", name: "GPT-5.1 (The Old LLM 🆓)", contextLength: 400000 }, + { id: "GPT_5", name: "GPT-5 (The Old LLM 🆓)", contextLength: 400000 }, + { id: "GPT_o4_mini", name: "o4-mini (The Old LLM 🆓)" }, + { id: "GPT_o3_mini", name: "o3-mini (The Old LLM 🆓)" }, + { id: "gemini_3_pro", name: "Gemini 3 Pro (The Old LLM 🆓)", contextLength: 1000000 }, + { id: "gemini_2_5_pro", name: "Gemini 2.5 Pro (The Old LLM 🆓)", contextLength: 1000000 }, + { id: "gemini_2_0_flash", name: "Gemini 2.0 Flash (The Old LLM 🆓)", contextLength: 1000000 }, + { id: "gemini_1_5_flash", name: "Gemini 1.5 Flash (The Old LLM 🆓)", contextLength: 1000000 }, + { id: "CLAUDE_4_6_OPUS", name: "Claude 4.6 Opus (The Old LLM 🆓)", contextLength: 200000 }, + { id: "CLAUDE_4_6_SONNET", name: "Claude 4.6 Sonnet (The Old LLM 🆓)", contextLength: 200000 }, + { id: "CLAUDE_4_5_HAIKU", name: "Claude 4.5 Haiku (The Old LLM 🆓)", contextLength: 200000 }, + { id: "openrouter_gpt_4_o", name: "GPT-4o (The Old LLM 🆓)" }, + { id: "openrouter_gpt_4_o_mini", name: "GPT-4o mini (The Old LLM 🆓)" }, + { id: "openrouter_grok_4", name: "Grok 4 (The Old LLM 🆓)" }, + { id: "together_deepseek_v3", name: "DeepSeek V3 (The Old LLM 🆓)" }, + { id: "openrouter_deepseek_r1", name: "DeepSeek R1 (The Old LLM 🆓)" }, + { id: "sonar-pro", name: "Sonar Pro (The Old LLM 🆓)" }, + // ── Legacy alias IDs (kept for saved-preference backward compatibility) ── { id: "GPT_4o", name: "GPT-4o (The Old LLM 🆓)" }, { id: "claude_opus_4", name: "Claude Opus 4 (The Old LLM 🆓)", contextLength: 200000 }, { id: "claude_sonnet_4", name: "Claude Sonnet 4 (The Old LLM 🆓)", contextLength: 200000 }, { id: "claude_haiku_3_5", name: "Claude Haiku 3.5 (The Old LLM 🆓)", contextLength: 200000 }, { id: "deepseek_v4", name: "DeepSeek V4 (The Old LLM 🆓)", contextLength: 200000 }, { id: "gemini_3_flash", name: "Gemini 3 Flash (The Old LLM 🆓)", contextLength: 1000000 }, - { id: "gemini_3_pro", name: "Gemini 3 Pro (The Old LLM 🆓)", contextLength: 1000000 }, ], passthroughModels: true, }; diff --git a/open-sse/config/providers/registry/x5lab/index.ts b/open-sse/config/providers/registry/x5lab/index.ts new file mode 100644 index 0000000000..ac5112a3a1 --- /dev/null +++ b/open-sse/config/providers/registry/x5lab/index.ts @@ -0,0 +1,15 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const x5labProvider: RegistryEntry = { + id: "x5lab", + alias: "x5lab", + format: "openai", + executor: "default", + baseUrl: "https://api.x5lab.dev/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + modelsUrl: "https://api.x5lab.dev/v1/models", + defaultContextLength: 128000, + models: [], + passthroughModels: true, +}; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 82d6314a5c..501fa4fcbf 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -4,7 +4,7 @@ export const xaiProvider: RegistryEntry = { id: "xai", alias: "xai", format: "openai", - executor: "default", + executor: "xai", baseUrl: "https://api.x.ai/v1/chat/completions", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 74393938ed..a66f58df06 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -46,7 +46,13 @@ import { } from "../services/cloudCodeThinking.ts"; import { buildGeminiTools } from "../translator/helpers/geminiToolsSanitizer.ts"; import { DEFAULT_SAFETY_SETTINGS } from "../translator/helpers/geminiHelper.ts"; -import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts"; +import { + type AntigravityCollectedStream, + processAntigravitySSEText, + flushAntigravitySSEText, +} from "./antigravity/sseCollect.ts"; +// processAntigravitySSEPayload re-exported for external importers (tests). +export { processAntigravitySSEPayload } from "./antigravity/sseCollect.ts"; import { applyAntigravityClientProfileHeaders, removeHeaderCaseInsensitive, @@ -155,70 +161,6 @@ function serializeAntigravityRequest( return applyFingerprint(provider, { ...headers }, serializedBody); } -type AntigravityCollectedStream = { - textContent: string; - finishReason: string; - toolCalls: Array<{ - id: string; - index: number; - type: "function"; - function: { name: string; arguments: string }; - }>; - usage: Record | null; - remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; -}; - -function stripZeroWidth(value: unknown): unknown { - if (typeof value === "string") { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); - } - if (Array.isArray(value)) { - return value.map((item) => stripZeroWidth(item)); - } - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record).map(([key, item]) => [ - key, - stripZeroWidth(item), - ]) - ); - } - return value; -} - -function parseAntigravityTextualToolCall(text: unknown): { name: string; args: unknown } | null { - if (typeof text !== "string") return null; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - const match = normalized.match( - /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ - ); - if (!match) return null; - const name = match[1]?.trim(); - const rawArgs = match[2]?.trim(); - if (!name || !rawArgs) return null; - try { - return { name, args: stripZeroWidth(JSON.parse(rawArgs)) }; - } catch { - return null; - } -} - -function addAntigravityTextualToolCall( - collected: AntigravityCollectedStream, - parsed: { name: string; args: unknown } -): void { - collected.toolCalls.push({ - id: `${parsed.name}-${Date.now()}-${collected.toolCalls.length}`, - index: collected.toolCalls.length, - type: "function", - function: { - name: parsed.name, - arguments: JSON.stringify(parsed.args || {}), - }, - }); - collected.finishReason = "tool_calls"; -} - type AntigravityRequestEnvelope = Record & { project: string; model?: string; @@ -372,84 +314,6 @@ export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit * tests (the markdown / candidate-parts extraction branches). @internal */ -export function processAntigravitySSEPayload( - payload: string, - collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } -) { - if (!payload || payload === "[DONE]") return; - try { - const parsed = JSON.parse(payload); - const markdown = - typeof parsed?.markdown === "string" - ? parsed.markdown - : typeof parsed?.response?.markdown === "string" - ? parsed.response.markdown - : null; - if (markdown) { - collected.textContent += markdown; - } - const candidate = parsed?.response?.candidates?.[0]; - if (candidate?.content?.parts) { - for (const part of candidate.content.parts) { - if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { - const textualToolCall = parseAntigravityTextualToolCall(part.text); - if (textualToolCall) { - addAntigravityTextualToolCall(collected, textualToolCall); - } else { - collected.textContent += part.text; - } - } - } - } - if (candidate?.finishReason) { - collected.finishReason = normalizeOpenAICompatibleFinishReasonString( - String(candidate.finishReason).toLowerCase() - ); - } - if (parsed?.response?.usageMetadata) { - const um = parsed.response.usageMetadata; - collected.usage = { - prompt_tokens: um.promptTokenCount || 0, - completion_tokens: um.candidatesTokenCount || 0, - total_tokens: um.totalTokenCount || 0, - }; - } - if (Array.isArray(parsed?.remainingCredits)) { - collected.remainingCredits = parsed.remainingCredits; - } - } catch { - log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`); - } -} - -function processAntigravitySSEText( - text: string, - partialLine: { value: string }, - collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } -) { - partialLine.value += text; - const lines = partialLine.value.split("\n"); - partialLine.value = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log); - } -} - -function flushAntigravitySSEText( - partialLine: { value: string }, - collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } -) { - const trimmed = partialLine.value.trim(); - partialLine.value = ""; - if (!trimmed.startsWith("data:")) return; - processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log); -} /** * Strip provider prefixes (e.g. "antigravity/model" → "model"). diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts new file mode 100644 index 0000000000..d42bab72e9 --- /dev/null +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -0,0 +1,148 @@ +// Pure SSE-payload -> collected-stream parsing for the Antigravity executor. +// Extracted verbatim from antigravity.ts (no host state, no fetch/auth). +import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; + +export type AntigravityCollectedStream = { + textContent: string; + finishReason: string; + toolCalls: Array<{ + id: string; + index: number; + type: "function"; + function: { name: string; arguments: string }; + }>; + usage: Record | null; + remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; +}; + +export function stripZeroWidth(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + } + if (Array.isArray(value)) { + return value.map((item) => stripZeroWidth(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + stripZeroWidth(item), + ]) + ); + } + return value; +} + +export function parseAntigravityTextualToolCall( + text: unknown +): { name: string; args: unknown } | null { + if (typeof text !== "string") return null; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const match = normalized.match( + /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ + ); + if (!match) return null; + const name = match[1]?.trim(); + const rawArgs = match[2]?.trim(); + if (!name || !rawArgs) return null; + try { + return { name, args: stripZeroWidth(JSON.parse(rawArgs)) }; + } catch { + return null; + } +} + +export function addAntigravityTextualToolCall( + collected: AntigravityCollectedStream, + parsed: { name: string; args: unknown } +): void { + collected.toolCalls.push({ + id: `${parsed.name}-${Date.now()}-${collected.toolCalls.length}`, + index: collected.toolCalls.length, + type: "function", + function: { + name: parsed.name, + arguments: JSON.stringify(parsed.args || {}), + }, + }); + collected.finishReason = "tool_calls"; +} + +export function processAntigravitySSEPayload( + payload: string, + collected: AntigravityCollectedStream, + log?: { debug?: (scope: string, message: string) => void } +) { + if (!payload || payload === "[DONE]") return; + try { + const parsed = JSON.parse(payload); + const markdown = + typeof parsed?.markdown === "string" + ? parsed.markdown + : typeof parsed?.response?.markdown === "string" + ? parsed.response.markdown + : null; + if (markdown) { + collected.textContent += markdown; + } + const candidate = parsed?.response?.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { + const textualToolCall = parseAntigravityTextualToolCall(part.text); + if (textualToolCall) { + addAntigravityTextualToolCall(collected, textualToolCall); + } else { + collected.textContent += part.text; + } + } + } + } + if (candidate?.finishReason) { + collected.finishReason = normalizeOpenAICompatibleFinishReasonString( + String(candidate.finishReason).toLowerCase() + ); + } + if (parsed?.response?.usageMetadata) { + const um = parsed.response.usageMetadata; + collected.usage = { + prompt_tokens: um.promptTokenCount || 0, + completion_tokens: um.candidatesTokenCount || 0, + total_tokens: um.totalTokenCount || 0, + }; + } + if (Array.isArray(parsed?.remainingCredits)) { + collected.remainingCredits = parsed.remainingCredits; + } + } catch { + log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`); + } +} + +export function processAntigravitySSEText( + text: string, + partialLine: { value: string }, + collected: AntigravityCollectedStream, + log?: { debug?: (scope: string, message: string) => void } +) { + partialLine.value += text; + const lines = partialLine.value.split("\n"); + partialLine.value = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log); + } +} + +export function flushAntigravitySSEText( + partialLine: { value: string }, + collected: AntigravityCollectedStream, + log?: { debug?: (scope: string, message: string) => void } +) { + const trimmed = partialLine.value.trim(); + partialLine.value = ""; + if (!trimmed.startsWith("data:")) return; + processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log); +} diff --git a/open-sse/executors/auggie.ts b/open-sse/executors/auggie.ts new file mode 100644 index 0000000000..f15d82c7e3 --- /dev/null +++ b/open-sse/executors/auggie.ts @@ -0,0 +1,569 @@ +/** + * AuggieExecutor — routes completions through the local Augment CLI ("auggie") + * binary via a one-shot stdin/stdout text pipe (no JSON-RPC / ACP protocol). + * + * Flow: + * 1. Flatten the OpenAI-shaped `messages[]` into a single prompt string. + * 2. Spawn `auggie --print --quiet --model ` and pipe the prompt on stdin. + * 3. Relay stdout chunks as OpenAI-compatible SSE deltas (stream=true) or + * buffer them into a single chat.completion JSON body (stream=false). + * 4. Kill the subprocess on abort / stream close. + * + * Authentication: + * None. Auggie delegates auth entirely to the user's local `auggie login` + * session — OmniRoute never sees or stores credentials for this provider. + * The connection is registered `noAuth: true` and `refreshCredentials()` is + * a no-op (nothing to refresh). + * + * Binary discovery: + * 1. AUGGIE_BIN / CLI_AUGGIE_BIN env var (absolute path override) + * 2. PATH lookup ("auggie" / "auggie.cmd") + * 3. %LOCALAPPDATA%\auggie\bin\auggie.exe (Windows installer) + * 4. ~/.local/share/auggie/bin/auggie (Linux installer) + * 5. ~/.auggie/bin/auggie (alternate installer layout) + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { auggieProvider } from "../config/providers/registry/auggie/index.ts"; + +const AUGGIE_URL = "auggie://cli/stdio"; + +// ─── Model allowlist (argument-injection defense) ───────────────────────────── +// The `model` value is forwarded straight into the `auggie` argv, so it is an +// untrusted-input sink. We only ever pass a model that is declared in the +// registry entry — this closes flag-smuggling (a `model` starting with "-" would +// otherwise be parsed by auggie as an option) and unknown-model passthrough. +const AUGGIE_MODEL_ALLOWLIST: ReadonlySet = new Set( + auggieProvider.models.map((m) => m.id) +); +const DEFAULT_AUGGIE_MODEL = auggieProvider.models[0]?.id ?? "claude-sonnet-4.6"; + +type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string }; + +/** + * Validate + resolve the requested model against the registry allowlist. + * Rejects flag-smuggling (leading "-") and any id not declared in the registry. + * An empty/absent model resolves to the registry's first (default) model. + */ +export function resolveAuggieModel(model: unknown): AuggieModelResolution { + const requested = typeof model === "string" ? model.trim() : ""; + if (!requested) return { ok: true, model: DEFAULT_AUGGIE_MODEL }; + if (requested.startsWith("-")) { + return { + ok: false, + error: `Invalid Auggie model "${requested}": model must not start with "-".`, + }; + } + if (!AUGGIE_MODEL_ALLOWLIST.has(requested)) { + return { + ok: false, + error: `Unknown Auggie model "${requested}". Supported models: ${[ + ...AUGGIE_MODEL_ALLOWLIST, + ].join(", ")}.`, + }; + } + return { ok: true, model: requested }; +} + +/** + * Build the auggie argv. `model` MUST already be allowlist-validated via + * resolveAuggieModel(). The trailing `--` marks the end of options so no + * later positional value can be reinterpreted as a flag. + */ +function buildAuggieArgs(model: string): string[] { + return ["--print", "--quiet", "--model", model, "--"]; +} + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +export function resolveAuggieBin(): string { + // 1. Explicit override + const envBin = (process.env.AUGGIE_BIN || process.env.CLI_AUGGIE_BIN || "").trim(); + if (envBin) return envBin; + + const isWin = process.platform === "win32"; + + // 2. Windows installer default: %LOCALAPPDATA%\auggie\bin\auggie.exe + if (isWin) { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + const winPath = path.join(localAppData, "auggie", "bin", "auggie.exe"); + if (fs.existsSync(winPath)) return winPath; + } + + // 3. Linux/macOS installer paths + const home = os.homedir(); + for (const candidate of [ + path.join(home, ".local", "share", "auggie", "bin", "auggie"), + path.join(home, ".auggie", "bin", "auggie"), + ]) { + if (fs.existsSync(candidate)) return candidate; + } + + // Fallback — rely on PATH + return isWin ? "auggie.cmd" : "auggie"; +} + +// ─── Multi-turn message → single prompt builder ─────────────────────────────── + +type OpenAIMsg = { role?: string; content?: unknown }; + +export function buildAuggiePrompt(messages: OpenAIMsg[]): string { + const lines: string[] = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (p && typeof p === "object" && (p as Record).type === "text") { + text += String((p as Record).text || ""); + } + } + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +function isEnoentLike(message: string): boolean { + return message.includes("ENOENT") || message.includes("not found"); +} + +export type AuggieCliVersionCheck = { ok: boolean; version?: string; error?: string }; + +/** + * Spawn `auggie --version` to confirm the local CLI is installed and runnable. + * Used by the provider "Test Connection" flow — auggie has no API key, so this + * is the only real signal we can give the operator before their first chat call. + */ +export function checkAuggieCliVersion(timeoutMs = 5000): Promise { + const bin = resolveAuggieBin(); + return new Promise((resolve) => { + let settled = false; + const settle = (result: AuggieCliVersionCheck) => { + if (settled) return; + settled = true; + resolve(result); + }; + + let child: ReturnType; + try { + // No `shell` option — fixed argv, no cmd.exe interpretation. + child = spawn(bin, ["--version"], { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + settle({ ok: false, error: isEnoentLike(message) ? cliNotFoundMessage(bin) : message }); + return; + } + + const timer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + settle({ ok: false, error: "Auggie CLI version check timed out" }); + }, timeoutMs); + + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.on("error", (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + const message = err?.message || String(err); + settle({ ok: false, error: isEnoentLike(message) ? cliNotFoundMessage(bin) : message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0 && stdout.trim()) { + settle({ ok: true, version: stdout.trim().slice(0, 200) }); + } else { + settle({ ok: false, error: `Auggie CLI exited with code ${code}` }); + } + }); + }); +} + +function cliNotFoundMessage(bin: string): string { + return sanitizeErrorMessage( + `Auggie CLI not found: ${bin}. Install it and run "auggie login", or set AUGGIE_BIN to an absolute path.` + ); +} + +// ─── AuggieExecutor ───────────────────────────────────────────────────────── + +export class AuggieExecutor extends BaseExecutor { + constructor() { + super("auggie", { id: "auggie", baseUrl: "" }); + } + + buildUrl(): string { + return AUGGIE_URL; + } + + buildHeaders(): Record { + return {}; + } + + transformRequest(): unknown { + return null; + } + + /** No-op — auggie has no OmniRoute-managed credentials to refresh. */ + async refreshCredentials( + _credentials: ProviderCredentials + ): Promise | null> { + return null; + } + + async execute({ + model, + body, + stream, + signal, + log, + }: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const b = (body ?? {}) as Record; + const messages: OpenAIMsg[] = Array.isArray(b.messages) ? (b.messages as OpenAIMsg[]) : []; + const promptText = buildAuggiePrompt(messages); + const auggieBin = resolveAuggieBin(); + const wantsStream = stream !== false; + + // Argument-injection defense: never forward an unvalidated model into the argv. + const modelResolution = resolveAuggieModel(model); + if (!modelResolution.ok) { + const response = wantsStream + ? buildAuggieSseError(modelResolution.error) + : errorResponse(400, modelResolution.error); + return { response, url: AUGGIE_URL, headers: {}, transformedBody: { error: true } }; + } + const safeModel = modelResolution.model; + + log?.info?.( + "AUGGIE", + `auggie --print → model=${safeModel}, bin=${auggieBin}, stream=${wantsStream}` + ); + + const response = wantsStream + ? this.runStreaming(auggieBin, safeModel, promptText, signal, log) + : await this.runNonStreaming(auggieBin, safeModel, promptText, signal, log); + + return { + response, + url: AUGGIE_URL, + headers: {}, + transformedBody: { model: safeModel, promptLength: promptText.length }, + }; + } + + private spawnAuggie(auggieBin: string, model: string, promptText: string) { + // No `shell` option: argv is passed directly to the OS loader, so no cmd.exe + // metacharacter interpretation is possible even on Windows. `model` is already + // allowlist-validated by resolveAuggieModel() before reaching here. + const child = spawn(auggieBin, buildAuggieArgs(model), { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + try { + child.stdin.write(promptText); + child.stdin.end(); + } catch { + /* ignore write errors — 'error'/'close' handlers surface the failure */ + } + return child; + } + + private runStreaming( + auggieBin: string, + model: string, + promptText: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Response { + const responseId = `chatcmpl-auggie-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data: string) => controller.enqueue(enc.encode(data)); + let closed = false; + let roleEmitted = false; + let finished = false; + + const finish = () => { + if (finished) return; + finished = true; + if (!closed) { + closed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + } + }; + + const emitDelta = (delta: string) => { + if (!delta) return; + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }, + ], + })}\n\n` + ); + roleEmitted = true; + } + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + }; + + const emitError = (message: string) => { + emit(`data: ${JSON.stringify(buildErrorBody(502, message))}\n\n`); + emit("data: [DONE]\n\n"); + finish(); + }; + + const emitStop = () => { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n` + ); + emit("data: [DONE]\n\n"); + finish(); + }; + + let child: ReturnType; + try { + // No `shell` option — argv goes straight to the OS loader (no cmd.exe + // interpretation). `model` is already allowlist-validated upstream. + child = spawn(auggieBin, buildAuggieArgs(model), { + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitError(isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message)); + return; + } + + try { + child.stdin.write(promptText); + child.stdin.end(); + } catch { + /* ignore — error/close handlers below surface failures */ + } + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + finish(); + }); + } + + child.on("error", (err: NodeJS.ErrnoException) => { + const message = err?.message || String(err); + emitError(isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message)); + }); + + let stderrTail = ""; + child.stdout?.on("data", (chunk: Buffer) => { + emitDelta(chunk.toString("utf8")); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); + log?.debug?.("AUGGIE", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (finished) return; + if (code !== 0) { + emitError( + sanitizeErrorMessage( + `Auggie CLI exited with code ${code}${stderrTail ? `: ${stderrTail}` : ""}` + ) + ); + return; + } + emitStop(); + }); + }, + cancel() { + // Stream cancelled by the consumer — nothing extra to clean up here; + // the abort-signal listener above (if provided) handles process kill. + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + private runNonStreaming( + auggieBin: string, + model: string, + promptText: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = this.spawnAuggie(auggieBin, model, promptText); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + resolve( + buildAuggieErrorResponse( + isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message) + ) + ); + return; + } + + let stdout = ""; + let stderrTail = ""; + let settled = false; + + const settle = (response: Response) => { + if (settled) return; + settled = true; + resolve(response); + }; + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + settle(buildAuggieErrorResponse(sanitizeErrorMessage("Auggie CLI request aborted"))); + }); + } + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-2000); + log?.debug?.("AUGGIE", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + const message = err?.message || String(err); + settle( + buildAuggieErrorResponse( + isEnoentLike(message) ? cliNotFoundMessage(auggieBin) : sanitizeErrorMessage(message) + ) + ); + }); + + child.on("close", (code) => { + if (code !== 0) { + settle( + buildAuggieErrorResponse( + sanitizeErrorMessage( + `Auggie CLI exited with code ${code}${stderrTail ? `: ${stderrTail}` : ""}` + ) + ) + ); + return; + } + settle(buildChatCompletionResponse(model, promptText, stdout)); + }); + }); + } +} + +function buildChatCompletionResponse(model: string, promptText: string, content: string): Response { + const trimmed = content.trim(); + const body = { + id: `chatcmpl-auggie-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: trimmed }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(trimmed.length / 4), + total_tokens: Math.ceil((promptText.length + trimmed.length) / 4), + estimated: true, + }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function buildAuggieErrorResponse(message: string): Response { + return errorResponse(502, message); +} + +/** + * Build a one-shot SSE error Response (single sanitized error event + [DONE]). + * Used for pre-spawn rejections on the streaming path (e.g. an invalid model) + * where no subprocess is ever started. + */ +function buildAuggieSseError(message: string): Response { + const enc = new TextEncoder(); + const sseStream = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(`data: ${JSON.stringify(buildErrorBody(400, message))}\n\n`)); + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 96685152c2..59e64bde69 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -66,6 +66,26 @@ import { stripProxyToolPrefix, } from "./claudeIdentity.ts"; import { withForcedResponsesUpstream } from "./forceResponsesUpstream.ts"; +import { + mergeUpstreamExtraHeaders, + setUserAgentHeader, + applyConfiguredUserAgent, + stripStainlessHeadersForOpenAICompat, +} from "./base/headers.ts"; +// Header helpers extracted to a pure leaf; re-exported for external importers +// (executors + tests) that import them from "./base.ts". +export { + mergeUpstreamExtraHeaders, + getCustomUserAgent, + setUserAgentHeader, + applyConfiguredUserAgent, + isOpenAICompatibleEndpoint, + stripStainlessHeadersForOpenAICompat, +} from "./base/headers.ts"; +import { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; +// Reasoning-effort sanitation extracted to a pure leaf; re-exported for external +// importers (mimoThinking service + tests) that import it from "./base.ts". +export { sanitizeReasoningEffortForProvider } from "./base/reasoningEffort.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -155,95 +175,6 @@ export type CountTokensInput = { signal?: AbortSignal | null; }; -/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */ -export function mergeUpstreamExtraHeaders( - headers: Record, - extra?: Record | null -): void { - if (!extra) return; - for (const [k, v] of Object.entries(extra)) { - if (typeof k === "string" && k.length > 0 && typeof v === "string") { - if (k.toLowerCase() === "user-agent") { - setUserAgentHeader(headers, v); - continue; - } - headers[k] = v; - } - } -} - -export function getCustomUserAgent(providerSpecificData?: JsonRecord | null): string | null { - const customUserAgent = - typeof providerSpecificData?.customUserAgent === "string" - ? providerSpecificData.customUserAgent.trim() - : ""; - return customUserAgent || null; -} - -export function setUserAgentHeader(headers: Record, userAgent: string): void { - headers["User-Agent"] = userAgent; - if ("user-agent" in headers) { - headers["user-agent"] = userAgent; - } -} - -export function applyConfiguredUserAgent( - headers: Record, - providerSpecificData?: JsonRecord | null -): void { - const customUserAgent = getCustomUserAgent(providerSpecificData); - if (customUserAgent) { - setUserAgentHeader(headers, customUserAgent); - } -} - -/** - * Returns true when the outbound request targets an OpenAI-compatible endpoint - * (a `openai-compatible-*` provider, or a Chat Completions / Responses URL). - * Used to scope the X-Stainless strip narrowly so genuine SDK-spoofing paths - * (e.g. Claude Code compat, which legitimately ADDS X-Stainless-*) are untouched. - */ -export function isOpenAICompatibleEndpoint(provider: string, url: string): boolean { - if (provider?.startsWith?.("openai-compatible-")) return true; - return url.includes("/v1/chat/completions") || url.includes("/v1/responses"); -} - -/** - * Strip OpenAI SDK (`X-Stainless-*`) metadata headers and normalize an SDK-derived - * User-Agent for OpenAI-compatible passthrough requests. Some upstream gateways - * 403 on these SDK-identifying headers. Only applied to OpenAI-compatible endpoints — - * other providers (Claude/Claude Code compat) may legitimately send X-Stainless-*. - * - * Mutates `headers` in place and returns the list of stripped header keys (for logging). - */ -export function stripStainlessHeadersForOpenAICompat( - headers: Record, - provider: string, - url: string -): string[] { - if (!isOpenAICompatibleEndpoint(provider, url)) return []; - - const strippedKeys: string[] = []; - for (const key of Object.keys(headers)) { - if (key.toLowerCase().startsWith("x-stainless-")) { - delete headers[key]; - strippedKeys.push(key); - } - } - - // Normalize User-Agent: SDK-based clients send verbose product strings that some - // upstreams block. Replace with a clean browser-like UA only when it looks SDK-derived. - const ua = (headers["User-Agent"] || headers["user-agent"] || "").toLowerCase(); - if ( - ua.includes("openai") && - (ua.includes("node") || ua.includes("axios") || ua.includes("undici")) - ) { - setUserAgentHeader(headers, "Mozilla/5.0 (compatible; OpenAI Compatible)"); - } - - return strippedKeys; -} - export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); @@ -272,161 +203,6 @@ function hasActiveClaudeThinking(body: Record): boolean { return thinking?.type === "enabled" || thinking?.type === "adaptive"; } -/** - * Sanitize reasoning_effort for providers that don't accept all values. - * - * The claude→openai translator may emit reasoning_effort=max/xhigh when the - * client sends output_config.effort=max on a Claude-shape request. Combined with - * runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this - * routes xhigh to OpenAI-shape providers that don't accept the value: - * - * xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh - * mistral : devstral models reject reasoning_effort entirely - * github : claude/haiku/oswe models reject reasoning_effort entirely - * - * Each rejection burns a combo fallback attempt before reaching a working - * provider. Apply provider-aware sanitation here (after transformRequest, so - * reintroductions by per-provider transforms are also caught) before fetch. - * xhigh support is opt-out: pass through unchanged unless the registry marks - * a model as unsupported. Literal max support is provider-specific and - * intentionally separate: some upstreams accept max even when they do not - * accept xhigh. For OpenAI-shape providers, max normalizes to xhigh by default - * and falls back to high only for explicit xhigh opt-outs. - */ -const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; -// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791): -// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to -// Anthropic's chat/completions surface, which honors reasoning_effort and -// emits visible reasoning tokens (verified upstream: 3× token increase -// between low/medium/high). -// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by -// Copilot's Claude backend), older Claude variants, all `haiku`-named -// models, and the `oswe-*` family (Raptor) which still rejects -// reasoning_effort. -// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip. -const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; -const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; - -function supportsMaxEffortForProvider(provider: string, model: string): boolean { - const isClaude = - (provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) && - supportsClaudeMaxEffort(model); - // opencode-go proxies DeepSeek with the native DeepSeek API contract, which - // accepts {high, max} literally. Without this opt-in, max would be - // normalized to xhigh (the OmniRoute-internal top tier) and rejected by the - // upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path - // (pi#4055) is the documented inverse and expects xhigh, not max. - // Ollama Cloud also accepts literal max (for example GLM 5.2 supports - // low|medium|high|max|none) and rejects xhigh. - const isOpencodeGoDeepSeek = - provider === "opencode-go" && model.toLowerCase().includes("deepseek"); - const isOllamaCloud = provider === "ollama-cloud"; - return isClaude || isOpencodeGoDeepSeek || isOllamaCloud; -} - -export function sanitizeReasoningEffortForProvider( - body: unknown, - provider: string, - model: string | undefined, - log?: { info?: (tag: string, msg: string) => void } | null -): unknown { - if (!body || typeof body !== "object" || Array.isArray(body)) return body; - const b = body as Record; - const reasoning = - b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning) - ? (b.reasoning as Record) - : null; - const hasTopLevelReasoningEffort = Object.prototype.hasOwnProperty.call(b, "reasoning_effort"); - const effort = b.reasoning_effort ?? reasoning?.effort; - if (effort === undefined) return body; - const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; - const modelStr = model || ""; - - const githubOptIn = - provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr); - const rejecting = - (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || - (provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); - if (rejecting) { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: removed unsupported reasoning_effort` - ); - const next: Record = { ...b }; - delete next.reasoning_effort; - if (reasoning) { - const r = { ...reasoning }; - delete r.effort; - if (Object.keys(r).length === 0) delete next.reasoning; - else next.reasoning = r; - } - return next; - } - - // Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort - // ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal - // scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's - // vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor). - // high/max pass through unchanged. Without this, the claude→openai translator's - // xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value, - // silently dropping the client's requested effort. This is the INVERSE of the - // OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055). - if (provider === "deepseek") { - const mapped = - effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null; - if (mapped && mapped !== effortStr) { - log?.info?.( - "REASONING_SANITIZE", - `deepseek/${modelStr}: normalized reasoning_effort ${effortStr} → ${mapped}` - ); - const next: Record = { ...b }; - if (hasTopLevelReasoningEffort) next.reasoning_effort = mapped; - if (reasoning) next.reasoning = { ...reasoning, effort: mapped }; - return next; - } - return body; - } - - const supportsXHigh = supportsXHighEffort(provider, modelStr); - const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; - const supportsXHighForMax = supportsXHigh; - const supportsMax = supportsMaxEffortForProvider(provider, modelStr); - const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; - const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; - - if (shouldNormalizeMaxToXHigh) { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: normalized reasoning_effort max → xhigh` - ); - const next: Record = { ...b }; - if (hasTopLevelReasoningEffort) { - next.reasoning_effort = "xhigh"; - } - if (reasoning) { - next.reasoning = { ...reasoning, effort: "xhigh" }; - } - return next; - } - - if (shouldDowngradeXHigh || shouldDowngradeMax) { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high` - ); - const next: Record = { ...b }; - if (hasTopLevelReasoningEffort) { - next.reasoning_effort = "high"; - } - if (reasoning) { - next.reasoning = { ...reasoning, effort: "high" }; - } - return next; - } - - return body; -} - /** * Strip the OmniRoute provider prefix from versioned built-in tool model * fields (e.g. `cc/claude-opus-4-8` → `claude-opus-4-8`). Versioned built-in diff --git a/open-sse/executors/base/headers.ts b/open-sse/executors/base/headers.ts new file mode 100644 index 0000000000..5aebfbb5cb --- /dev/null +++ b/open-sse/executors/base/headers.ts @@ -0,0 +1,93 @@ +// Pure upstream header helpers (User-Agent, extra headers, OpenAI-compat stripping). +// Extracted verbatim from base.ts. Module-private JsonRecord kept local to avoid a cycle. + +type JsonRecord = Record; + +/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */ +export function mergeUpstreamExtraHeaders( + headers: Record, + extra?: Record | null +): void { + if (!extra) return; + for (const [k, v] of Object.entries(extra)) { + if (typeof k === "string" && k.length > 0 && typeof v === "string") { + if (k.toLowerCase() === "user-agent") { + setUserAgentHeader(headers, v); + continue; + } + headers[k] = v; + } + } +} + +export function getCustomUserAgent(providerSpecificData?: JsonRecord | null): string | null { + const customUserAgent = + typeof providerSpecificData?.customUserAgent === "string" + ? providerSpecificData.customUserAgent.trim() + : ""; + return customUserAgent || null; +} + +export function setUserAgentHeader(headers: Record, userAgent: string): void { + headers["User-Agent"] = userAgent; + if ("user-agent" in headers) { + headers["user-agent"] = userAgent; + } +} + +export function applyConfiguredUserAgent( + headers: Record, + providerSpecificData?: JsonRecord | null +): void { + const customUserAgent = getCustomUserAgent(providerSpecificData); + if (customUserAgent) { + setUserAgentHeader(headers, customUserAgent); + } +} + +/** + * Returns true when the outbound request targets an OpenAI-compatible endpoint + * (a `openai-compatible-*` provider, or a Chat Completions / Responses URL). + * Used to scope the X-Stainless strip narrowly so genuine SDK-spoofing paths + * (e.g. Claude Code compat, which legitimately ADDS X-Stainless-*) are untouched. + */ +export function isOpenAICompatibleEndpoint(provider: string, url: string): boolean { + if (provider?.startsWith?.("openai-compatible-")) return true; + return url.includes("/v1/chat/completions") || url.includes("/v1/responses"); +} + +/** + * Strip OpenAI SDK (`X-Stainless-*`) metadata headers and normalize an SDK-derived + * User-Agent for OpenAI-compatible passthrough requests. Some upstream gateways + * 403 on these SDK-identifying headers. Only applied to OpenAI-compatible endpoints — + * other providers (Claude/Claude Code compat) may legitimately send X-Stainless-*. + * + * Mutates `headers` in place and returns the list of stripped header keys (for logging). + */ +export function stripStainlessHeadersForOpenAICompat( + headers: Record, + provider: string, + url: string +): string[] { + if (!isOpenAICompatibleEndpoint(provider, url)) return []; + + const strippedKeys: string[] = []; + for (const key of Object.keys(headers)) { + if (key.toLowerCase().startsWith("x-stainless-")) { + delete headers[key]; + strippedKeys.push(key); + } + } + + // Normalize User-Agent: SDK-based clients send verbose product strings that some + // upstreams block. Replace with a clean browser-like UA only when it looks SDK-derived. + const ua = (headers["User-Agent"] || headers["user-agent"] || "").toLowerCase(); + if ( + ua.includes("openai") && + (ua.includes("node") || ua.includes("axios") || ua.includes("undici")) + ) { + setUserAgentHeader(headers, "Mozilla/5.0 (compatible; OpenAI Compatible)"); + } + + return strippedKeys; +} diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts new file mode 100644 index 0000000000..dcabf6ef26 --- /dev/null +++ b/open-sse/executors/base/reasoningEffort.ts @@ -0,0 +1,160 @@ +// Provider-aware reasoning_effort sanitation (xhigh/max normalization + reject strip). +// Extracted verbatim from base.ts. Deps are config/services only (no host import → no cycle). +import { PROVIDER_CLAUDE } from "../../services/systemTransforms.ts"; +import { isClaudeCodeCompatible } from "../../services/provider.ts"; +import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts"; + +/** + * Sanitize reasoning_effort for providers that don't accept all values. + * + * The claude→openai translator may emit reasoning_effort=max/xhigh when the + * client sends output_config.effort=max on a Claude-shape request. Combined with + * runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this + * routes xhigh to OpenAI-shape providers that don't accept the value: + * + * xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh + * mistral : devstral models reject reasoning_effort entirely + * github : claude/haiku/oswe models reject reasoning_effort entirely + * + * Each rejection burns a combo fallback attempt before reaching a working + * provider. Apply provider-aware sanitation here (after transformRequest, so + * reintroductions by per-provider transforms are also caught) before fetch. + * xhigh support is opt-out: pass through unchanged unless the registry marks + * a model as unsupported. Literal max support is provider-specific and + * intentionally separate: some upstreams accept max even when they do not + * accept xhigh. For OpenAI-shape providers, max normalizes to xhigh by default + * and falls back to high only for explicit xhigh opt-outs. + */ +export const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; +// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791): +// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to +// Anthropic's chat/completions surface, which honors reasoning_effort and +// emits visible reasoning tokens (verified upstream: 3× token increase +// between low/medium/high). +// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by +// Copilot's Claude backend), older Claude variants, all `haiku`-named +// models, and the `oswe-*` family (Raptor) which still rejects +// reasoning_effort. +// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip. +export const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; +export const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; + +export function supportsMaxEffortForProvider(provider: string, model: string): boolean { + const isClaude = + (provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) && + supportsClaudeMaxEffort(model); + // opencode-go proxies DeepSeek with the native DeepSeek API contract, which + // accepts {high, max} literally. Without this opt-in, max would be + // normalized to xhigh (the OmniRoute-internal top tier) and rejected by the + // upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path + // (pi#4055) is the documented inverse and expects xhigh, not max. + // Ollama Cloud also accepts literal max (for example GLM 5.2 supports + // low|medium|high|max|none) and rejects xhigh. + const isOpencodeGoDeepSeek = + provider === "opencode-go" && model.toLowerCase().includes("deepseek"); + const isOllamaCloud = provider === "ollama-cloud"; + return isClaude || isOpencodeGoDeepSeek || isOllamaCloud; +} + +export function sanitizeReasoningEffortForProvider( + body: unknown, + provider: string, + model: string | undefined, + log?: { info?: (tag: string, msg: string) => void } | null +): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const b = body as Record; + const reasoning = + b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning) + ? (b.reasoning as Record) + : null; + const hasTopLevelReasoningEffort = Object.prototype.hasOwnProperty.call(b, "reasoning_effort"); + const effort = b.reasoning_effort ?? reasoning?.effort; + if (effort === undefined) return body; + const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; + const modelStr = model || ""; + + const githubOptIn = + provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr); + const rejecting = + (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || + (provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); + if (rejecting) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: removed unsupported reasoning_effort` + ); + const next: Record = { ...b }; + delete next.reasoning_effort; + if (reasoning) { + const r = { ...reasoning }; + delete r.effort; + if (Object.keys(r).length === 0) delete next.reasoning; + else next.reasoning = r; + } + return next; + } + + // Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort + // ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal + // scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's + // vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor). + // high/max pass through unchanged. Without this, the claude→openai translator's + // xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value, + // silently dropping the client's requested effort. This is the INVERSE of the + // OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055). + if (provider === "deepseek") { + const mapped = + effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null; + if (mapped && mapped !== effortStr) { + log?.info?.( + "REASONING_SANITIZE", + `deepseek/${modelStr}: normalized reasoning_effort ${effortStr} → ${mapped}` + ); + const next: Record = { ...b }; + if (hasTopLevelReasoningEffort) next.reasoning_effort = mapped; + if (reasoning) next.reasoning = { ...reasoning, effort: mapped }; + return next; + } + return body; + } + + const supportsXHigh = supportsXHighEffort(provider, modelStr); + const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; + const supportsXHighForMax = supportsXHigh; + const supportsMax = supportsMaxEffortForProvider(provider, modelStr); + const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; + const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; + + if (shouldNormalizeMaxToXHigh) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: normalized reasoning_effort max → xhigh` + ); + const next: Record = { ...b }; + if (hasTopLevelReasoningEffort) { + next.reasoning_effort = "xhigh"; + } + if (reasoning) { + next.reasoning = { ...reasoning, effort: "xhigh" }; + } + return next; + } + + if (shouldDowngradeXHigh || shouldDowngradeMax) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high` + ); + const next: Record = { ...b }; + if (hasTopLevelReasoningEffort) { + next.reasoning_effort = "high"; + } + if (reasoning) { + next.reasoning = { ...reasoning, effort: "high" }; + } + return next; + } + + return body; +} diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 87a4bd3f29..27085cf3c6 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -32,6 +32,7 @@ import { __resetChatGptImageCacheForTesting, type ChatGptImageConversationContext, } from "../services/chatgptImageCache.ts"; +import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -84,52 +85,6 @@ function deviceIdFor(cookie: string): string { // ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug // catalog comes from /backend-api/models on a logged-in account; // "gpt-5-4-t-mini" is ChatGPT's abbreviated slug for "GPT-5.4 Thinking Mini". -const MODEL_MAP: Record = { - // ChatGPT backend slugs are also accepted directly for power users / tests. - "gpt-5-5-pro": "gpt-5-5-pro", - "gpt-5-5-pro-extended": "gpt-5-5-pro", - "gpt-5-5-thinking": "gpt-5-5-thinking", - "gpt-5-5": "gpt-5-5", - "gpt-5-4-pro": "gpt-5-4-pro", - "gpt-5-4-thinking": "gpt-5-4-thinking", - "gpt-5-4-t-mini": "gpt-5-4-t-mini", - "gpt-5-3": "gpt-5-3", - "gpt-5-3-mini": "gpt-5-3-mini", - - // Public OmniRoute dot-form ids exposed by the provider catalog. - "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-pro-extended": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", - "gpt-5.5": "gpt-5-5", - "gpt-5.4-pro": "gpt-5-4-pro", - "gpt-5.4-thinking": "gpt-5-4-thinking", - "gpt-5.4-thinking-mini": "gpt-5-4-t-mini", - "gpt-5.3-instant": "gpt-5-3-instant", - "gpt-5.3": "gpt-5-3", - "gpt-5.3-mini": "gpt-5-3-mini", - o3: "o3", -}; - -const MODEL_FORCED_EFFORT: Record = { - "gpt-5-5-pro": "standard", - "gpt-5-5-pro-extended": "extended", - "gpt-5.5-pro": "standard", - "gpt-5.5-pro-extended": "extended", -}; - -/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint - * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a - * new thinking entry there automatically extends this set. Includes the - * abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the - * reason this set exists at all rather than a substring match. - * - * Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or - * are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */ -const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( - Object.entries(MODEL_MAP) - .filter(([k]) => k.includes("thinking") || k === "o3") - .map(([, v]) => v) -); // ─── Browser-like default headers ────────────────────────────────────────── @@ -472,90 +427,6 @@ const thinkingEffortCache = new Map(); const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000; const THINKING_EFFORT_CACHE_MAX = 400; -/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking - * models and the o-series. PATCHing for a non-thinking surface is a no-op - * (the server accepts it but the routing-time read picks the wrong knob). - * - * Three branches because the input can arrive in three shapes: - * 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking - * variant carries the literal "thinking" substring here. - * 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`). - * 3. Resolved chatgpt.com slug that drops the substring under abbreviation - * (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is - * derived from MODEL_MAP itself so adding a new abbreviated thinking - * mapping automatically extends the check. - * - * Branch 3 also catches the case where a caller passes the chatgpt.com slug - * directly as the `model` field (no MODEL_MAP translation needed), which - * would otherwise silently bypass the PATCH. */ -function isThinkingCapableModel(modelId: string, slug: string): boolean { - return ( - modelId.includes("thinking") || - modelId === "o3" || - slug.includes("thinking") || - THINKING_CAPABLE_SLUGS.has(slug) || - THINKING_CAPABLE_SLUGS.has(modelId) - ); -} - -/** Map either a chatgpt.com-native value (`standard`/`extended`) or the - * OpenAI Chat Completions `reasoning_effort` field to the value the - * `user_last_used_model_config` endpoint expects. - * - * minimal | low | medium | standard → standard - * high | xhigh | extended → extended - * - * `medium` collapses to `standard` because chatgpt.com only has two levels — - * there is no separate medium tier on the web product. Returns null for - * absent/unknown inputs. */ -function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null { - if (typeof input !== "string") return null; - const v = input.trim().toLowerCase(); - if (v === "extended" || v === "high" || v === "xhigh") return "extended"; - if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { - return "standard"; - } - return null; -} - -/** Resolve the requested effort for this turn. - * Order: `providerSpecificData.thinkingEffort` (raw override, takes - * `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI - * Chat Completions field) > `body.reasoning.effort` (Responses-API nesting). - * Returns null when the caller did not request one. */ -function resolveThinkingEffort( - body: unknown, - providerSpecificData: Record | undefined -): "standard" | "extended" | null { - if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { - return normalizeThinkingEffort(providerSpecificData.thinkingEffort); - } - const b = (body as Record | null) ?? null; - if (!b) return null; - const top = normalizeThinkingEffort(b.reasoning_effort); - if (top) return top; - const nested = (b.reasoning as Record | undefined)?.effort; - return normalizeThinkingEffort(nested); -} - -interface ResolvedChatGptModel { - slug: string; - effort: "standard" | "extended" | null; - isPro: boolean; -} - -function resolveChatGptModel( - model: string, - body: unknown, - providerSpecificData: Record | undefined -): ResolvedChatGptModel { - const slug = MODEL_MAP[model] ?? model; - const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; - const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); - const isPro = slug === "gpt-5-5-pro"; - return { slug, effort, isPro }; -} - function configuredProPollTimeoutMs(): number { const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS; @@ -1399,8 +1270,7 @@ async function* extractContent( // on a tool-role message (handled below). if (event.type === "server_ste_metadata") { const meta = (event as Record).metadata as - | Record - | undefined; + Record | undefined; if (meta && meta.turn_use_case === "image gen") { imageGenAsync = true; } @@ -2780,8 +2650,7 @@ export class ChatGptWebExecutor extends BaseExecutor { clientHeaders, }: ExecuteInput) { const messages = (body as Record | null)?.messages as - | Array> - | undefined; + Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { return { response: errorResponse(400, "Missing or empty messages array"), diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts new file mode 100644 index 0000000000..72738f43b0 --- /dev/null +++ b/open-sse/executors/chatgpt-web/models.ts @@ -0,0 +1,133 @@ +// Pure model-mapping / thinking-effort resolution for the ChatGPT-web executor. +// Extracted verbatim from chatgpt-web.ts (static maps + pure resolvers, no state). + +export const MODEL_MAP: Record = { + // ChatGPT backend slugs are also accepted directly for power users / tests. + "gpt-5-5-pro": "gpt-5-5-pro", + "gpt-5-5-pro-extended": "gpt-5-5-pro", + "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-5": "gpt-5-5", + "gpt-5-4-pro": "gpt-5-4-pro", + "gpt-5-4-thinking": "gpt-5-4-thinking", + "gpt-5-4-t-mini": "gpt-5-4-t-mini", + "gpt-5-3": "gpt-5-3", + "gpt-5-3-mini": "gpt-5-3-mini", + + // Public OmniRoute dot-form ids exposed by the provider catalog. + "gpt-5.5-pro": "gpt-5-5-pro", + "gpt-5.5-pro-extended": "gpt-5-5-pro", + "gpt-5.5-thinking": "gpt-5-5-thinking", + "gpt-5.5": "gpt-5-5", + "gpt-5.4-pro": "gpt-5-4-pro", + "gpt-5.4-thinking": "gpt-5-4-thinking", + "gpt-5.4-thinking-mini": "gpt-5-4-t-mini", + "gpt-5.3-instant": "gpt-5-3-instant", + "gpt-5.3": "gpt-5-3", + "gpt-5.3-mini": "gpt-5-3-mini", + o3: "o3", +}; + +export const MODEL_FORCED_EFFORT: Record = { + "gpt-5-5-pro": "standard", + "gpt-5-5-pro-extended": "extended", + "gpt-5.5-pro": "standard", + "gpt-5.5-pro-extended": "extended", +}; + +/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint + * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a + * new thinking entry there automatically extends this set. Includes the + * abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the + * reason this set exists at all rather than a substring match. + * + * Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or + * are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */ +export const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( + Object.entries(MODEL_MAP) + .filter(([k]) => k.includes("thinking") || k === "o3") + .map(([, v]) => v) +); + +/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking + * models and the o-series. PATCHing for a non-thinking surface is a no-op + * (the server accepts it but the routing-time read picks the wrong knob). + * + * Three branches because the input can arrive in three shapes: + * 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking + * variant carries the literal "thinking" substring here. + * 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`). + * 3. Resolved chatgpt.com slug that drops the substring under abbreviation + * (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is + * derived from MODEL_MAP itself so adding a new abbreviated thinking + * mapping automatically extends the check. + * + * Branch 3 also catches the case where a caller passes the chatgpt.com slug + * directly as the `model` field (no MODEL_MAP translation needed), which + * would otherwise silently bypass the PATCH. */ +export function isThinkingCapableModel(modelId: string, slug: string): boolean { + return ( + modelId.includes("thinking") || + modelId === "o3" || + slug.includes("thinking") || + THINKING_CAPABLE_SLUGS.has(slug) || + THINKING_CAPABLE_SLUGS.has(modelId) + ); +} + +/** Map either a chatgpt.com-native value (`standard`/`extended`) or the + * OpenAI Chat Completions `reasoning_effort` field to the value the + * `user_last_used_model_config` endpoint expects. + * + * minimal | low | medium | standard → standard + * high | xhigh | extended → extended + * + * `medium` collapses to `standard` because chatgpt.com only has two levels — + * there is no separate medium tier on the web product. Returns null for + * absent/unknown inputs. */ +export function normalizeThinkingEffort(input: unknown): "standard" | "extended" | null { + if (typeof input !== "string") return null; + const v = input.trim().toLowerCase(); + if (v === "extended" || v === "high" || v === "xhigh") return "extended"; + if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { + return "standard"; + } + return null; +} + +/** Resolve the requested effort for this turn. + * Order: `providerSpecificData.thinkingEffort` (raw override, takes + * `standard`/`extended` directly) > `body.reasoning_effort` (top-level OpenAI + * Chat Completions field) > `body.reasoning.effort` (Responses-API nesting). + * Returns null when the caller did not request one. */ +export function resolveThinkingEffort( + body: unknown, + providerSpecificData: Record | undefined +): "standard" | "extended" | null { + if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { + return normalizeThinkingEffort(providerSpecificData.thinkingEffort); + } + const b = (body as Record | null) ?? null; + if (!b) return null; + const top = normalizeThinkingEffort(b.reasoning_effort); + if (top) return top; + const nested = (b.reasoning as Record | undefined)?.effort; + return normalizeThinkingEffort(nested); +} + +export interface ResolvedChatGptModel { + slug: string; + effort: "standard" | "extended" | null; + isPro: boolean; +} + +export function resolveChatGptModel( + model: string, + body: unknown, + providerSpecificData: Record | undefined +): ResolvedChatGptModel { + const slug = MODEL_MAP[model] ?? model; + const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; + const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); + const isPro = slug === "gpt-5-5-pro"; + return { slug, effort, isPro }; +} diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts index 4a52506b33..55a1c5be91 100644 --- a/open-sse/executors/chatgptWebTools.ts +++ b/open-sse/executors/chatgptWebTools.ts @@ -1,13 +1,16 @@ -// Tool-call emulation helpers for the ChatGPT Web executor (#5240). +// Tool-call emulation helpers for web-cookie executors (#5240, #5927). // -// chatgpt.com has no native function calling. When the OpenAI request carries -// `tools`, the prompt-side shim (`prepareToolMessages` in -// ../translator/webTools.ts) injects a `` contract; on the response side -// we parse `{...}` blocks back into OpenAI `tool_calls` — -// mirroring the sibling web-session executors (qwen-web, perplexity-web, ...). +// Web-cookie providers (chatgpt-web, perplexity-web, ...) have no native +// function calling. When the OpenAI request carries `tools`, the prompt-side +// shim (`prepareToolMessages` in ../translator/webTools.ts) injects a `` +// contract; on the response side we parse `{...}` blocks back +// into OpenAI `tool_calls`. // -// The whole tool-mode orchestration lives here so the (frozen) chatgpt-web.ts -// only gains an import + a single delegating call. +// The whole tool-mode orchestration lives here — provider-agnostic — so each +// (frozen) executor only gains an import + a single delegating call. Despite +// the filename (kept for git-blame continuity from #5240, the first caller), +// this module is shared: `buildToolModeResponse()` accepts an `idSeed` so +// every provider gets its own `tool_calls[].id` prefix. import { buildToolAwareResult } from "../translator/webTools.ts"; @@ -28,7 +31,8 @@ function sseChunk(data: unknown): string { */ async function applyToolCallsToJsonResponse( response: Response, - requestedTools: unknown + requestedTools: unknown, + idSeed: string ): Promise { const bodyText = await response.text(); try { @@ -37,7 +41,7 @@ async function applyToolCallsToJsonResponse( const { content, toolCalls, finishReason } = buildToolAwareResult( rawContent, requestedTools, - "cgpt" + idSeed ); if (toolCalls) { json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; @@ -107,9 +111,13 @@ export async function buildToolModeResponse( bufferedJson: Response, requestedTools: unknown, stream: boolean, - meta: { cid: string; created: number; model: string } + meta: { cid: string; created: number; model: string; idSeed?: string } ): Promise { - const jsonResponse = await applyToolCallsToJsonResponse(bufferedJson, requestedTools); + const jsonResponse = await applyToolCallsToJsonResponse( + bufferedJson, + requestedTools, + meta.idSeed ?? "cgpt" + ); if (!stream) return jsonResponse; const completion = await jsonResponse.json(); return new Response(toolCompletionToSseStream(completion, meta.cid, meta.created, meta.model), { diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 6c384fcc7a..599814d072 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -27,6 +27,11 @@ import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { tryBackedChat } from "../services/browserBackedChat.ts"; +import { + type ClaudeWebRequestPayload, + transformToClaude, + transformFromClaude, +} from "./claude-web/payload.ts"; // ─── Constants ────────────────────────────────────────────────────────────── const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; @@ -87,74 +92,6 @@ function readClaudeWebDeviceId(credentials: unknown): string | undefined { return undefined; } -// Default model when not specified -const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; - -// ─── Types ────────────────────────────────────────────────────────────────── -/** - * Extended credentials to include organization and conversation context - */ -interface ClaudeWebRequestPayload { - prompt: string; - model: string; - timezone: string; - personalized_styles: Array<{ - type: string; - key: string; - name: string; - nameKey: string; - prompt: string; - summary: string; - summaryKey: string; - isDefault: boolean; - }>; - locale: string; - tools: Array<{ - name?: string; - description?: string; - input_schema?: Record; - integration_name?: string; - is_mcp_app?: boolean; - type?: string; - }>; - turn_message_uuids: { - human_message_uuid: string; - assistant_message_uuid: string; - }; - attachments: unknown[]; - effort: string; - files: unknown[]; - sync_sources: unknown[]; - rendering_mode: string; - thinking_mode: string; - create_conversation_params: { - name: string; - model: string; - include_conversation_preferences: boolean; - paprika_mode: unknown; - compass_mode: unknown; - is_temporary: boolean; - enabled_imagine: boolean; - tool_search_mode: string; - }; -} - -/** - * Stream chunk from Claude Web API - */ -interface ClaudeWebStreamChunk { - type?: string; - index?: number; - completion?: string; - stop_reason?: string | null; - model?: string; - delta?: { - type?: string; - text?: string; - }; - [key: string]: unknown; -} - // ─── Helper Functions ─────────────────────────────────────────────────────── /** @@ -230,166 +167,6 @@ async function normalizeClaudeSessionCookieWithAutoRefresh( return normalized; } -/** - * Generate UUIDs for turn message tracking - */ -function generateMessageUUIDs() { - return { - human_message_uuid: randomUUID(), - assistant_message_uuid: randomUUID(), - }; -} - -/** - * Get default tool definitions for Claude Web API - */ -function getDefaultTools(): ClaudeWebRequestPayload["tools"] { - return [ - { - name: "show_widget", - description: "Display interactive widgets and visualizations", - input_schema: { - type: "object", - properties: { - widget_type: { - type: "string", - description: "Type of widget to display", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: true, - }, - { - name: "read_me", - description: "Read and reference documents", - input_schema: { - type: "object", - properties: { - file_path: { - type: "string", - description: "Path to the file to read", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: false, - }, - { - type: "web_search_v0", - name: "web_search", - }, - { - type: "artifacts_v0", - name: "artifacts", - }, - { - type: "repl_v0", - name: "repl", - }, - { type: "widget", name: "weather_fetch" }, - { type: "widget", name: "recipe_display_v0" }, - { type: "widget", name: "places_map_display_v0" }, - { type: "widget", name: "message_compose_v1" }, - { type: "widget", name: "ask_user_input_v0" }, - { type: "widget", name: "recommend_claude_apps" }, - { type: "widget", name: "places_search" }, - { type: "widget", name: "fetch_sports_data" }, - ]; -} - -/** - * Get default personalized style - */ -function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personalized_styles"] { - return [ - { - type: "default", - key: "Default", - name: "Normal", - nameKey: "normal_style_name", - prompt: "Normal\n", - summary: "Default responses from Claude", - summaryKey: "normal_style_summary", - isDefault: true, - }, - ]; -} - -/** - * Transform OpenAI format to Claude Web format - */ -function transformToClaude(body: Record, model: string): ClaudeWebRequestPayload { - const messages = Array.isArray(body.messages) ? body.messages : []; - - // Extract the last user message as the prompt - let prompt = ""; - for (const msg of messages) { - if (typeof msg === "object" && msg !== null) { - const message = msg as Record; - if (message.role === "user") { - prompt = String(message.content || ""); - } - } - } - - if (!prompt.trim()) { - throw new Error("No user message found in request"); - } - - return { - prompt, - model: model || DEFAULT_CLAUDE_MODEL, - timezone: "Asia/Jakarta", - personalized_styles: getDefaultPersonalizedStyle(), - locale: "en-US", - tools: getDefaultTools(), - turn_message_uuids: generateMessageUUIDs(), - attachments: [], - effort: "low", - files: [], - sync_sources: [], - rendering_mode: "messages", - thinking_mode: "off", - create_conversation_params: { - name: "", - model: model || DEFAULT_CLAUDE_MODEL, - include_conversation_preferences: true, - paprika_mode: null, - compass_mode: null, - is_temporary: false, - enabled_imagine: true, - tool_search_mode: "auto", - }, - }; -} - -/** - * Transform Claude Web response to OpenAI format - */ -function transformFromClaude( - claudeContent: string, - model: string, - stopReason?: string -): Record { - return { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { - content: claudeContent, - }, - finish_reason: stopReason === "end_turn" ? "stop" : null, - logprobs: null, - }, - ], - }; -} - /** * Verify session is still valid by checking if the organizations endpoint * returns a successful response. Claude's API does not have a /api/auth/session diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts new file mode 100644 index 0000000000..cc0dcc3787 --- /dev/null +++ b/open-sse/executors/claude-web/payload.ts @@ -0,0 +1,230 @@ +// Pure Claude-web payload construction (types + transforms + default tools/style). +// Extracted verbatim from claude-web.ts. No host state, no fetch/auth. +import { randomUUID } from "crypto"; + +// Default model when not specified +export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; + +export interface ClaudeWebRequestPayload { + prompt: string; + model: string; + timezone: string; + personalized_styles: Array<{ + type: string; + key: string; + name: string; + nameKey: string; + prompt: string; + summary: string; + summaryKey: string; + isDefault: boolean; + }>; + locale: string; + tools: Array<{ + name?: string; + description?: string; + input_schema?: Record; + integration_name?: string; + is_mcp_app?: boolean; + type?: string; + }>; + turn_message_uuids: { + human_message_uuid: string; + assistant_message_uuid: string; + }; + attachments: unknown[]; + effort: string; + files: unknown[]; + sync_sources: unknown[]; + rendering_mode: string; + thinking_mode: string; + create_conversation_params: { + name: string; + model: string; + include_conversation_preferences: boolean; + paprika_mode: unknown; + compass_mode: unknown; + is_temporary: boolean; + enabled_imagine: boolean; + tool_search_mode: string; + }; +} + +/** + * Stream chunk from Claude Web API + */ +export interface ClaudeWebStreamChunk { + type?: string; + index?: number; + completion?: string; + stop_reason?: string | null; + model?: string; + delta?: { + type?: string; + text?: string; + }; + [key: string]: unknown; +} + +/** + * Generate UUIDs for turn message tracking + */ +export function generateMessageUUIDs() { + return { + human_message_uuid: randomUUID(), + assistant_message_uuid: randomUUID(), + }; +} + +/** + * Get default tool definitions for Claude Web API + */ +export function getDefaultTools(): ClaudeWebRequestPayload["tools"] { + return [ + { + name: "show_widget", + description: "Display interactive widgets and visualizations", + input_schema: { + type: "object", + properties: { + widget_type: { + type: "string", + description: "Type of widget to display", + }, + }, + }, + integration_name: "visualize", + is_mcp_app: true, + }, + { + name: "read_me", + description: "Read and reference documents", + input_schema: { + type: "object", + properties: { + file_path: { + type: "string", + description: "Path to the file to read", + }, + }, + }, + integration_name: "visualize", + is_mcp_app: false, + }, + { + type: "web_search_v0", + name: "web_search", + }, + { + type: "artifacts_v0", + name: "artifacts", + }, + { + type: "repl_v0", + name: "repl", + }, + { type: "widget", name: "weather_fetch" }, + { type: "widget", name: "recipe_display_v0" }, + { type: "widget", name: "places_map_display_v0" }, + { type: "widget", name: "message_compose_v1" }, + { type: "widget", name: "ask_user_input_v0" }, + { type: "widget", name: "recommend_claude_apps" }, + { type: "widget", name: "places_search" }, + { type: "widget", name: "fetch_sports_data" }, + ]; +} + +/** + * Get default personalized style + */ +export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personalized_styles"] { + return [ + { + type: "default", + key: "Default", + name: "Normal", + nameKey: "normal_style_name", + prompt: "Normal\n", + summary: "Default responses from Claude", + summaryKey: "normal_style_summary", + isDefault: true, + }, + ]; +} + +/** + * Transform OpenAI format to Claude Web format + */ +export function transformToClaude( + body: Record, + model: string +): ClaudeWebRequestPayload { + const messages = Array.isArray(body.messages) ? body.messages : []; + + // Extract the last user message as the prompt + let prompt = ""; + for (const msg of messages) { + if (typeof msg === "object" && msg !== null) { + const message = msg as Record; + if (message.role === "user") { + prompt = String(message.content || ""); + } + } + } + + if (!prompt.trim()) { + throw new Error("No user message found in request"); + } + + return { + prompt, + model: model || DEFAULT_CLAUDE_MODEL, + timezone: "Asia/Jakarta", + personalized_styles: getDefaultPersonalizedStyle(), + locale: "en-US", + tools: getDefaultTools(), + turn_message_uuids: generateMessageUUIDs(), + attachments: [], + effort: "low", + files: [], + sync_sources: [], + rendering_mode: "messages", + thinking_mode: "off", + create_conversation_params: { + name: "", + model: model || DEFAULT_CLAUDE_MODEL, + include_conversation_preferences: true, + paprika_mode: null, + compass_mode: null, + is_temporary: false, + enabled_imagine: true, + tool_search_mode: "auto", + }, + }; +} + +/** + * Transform Claude Web response to OpenAI format + */ +export function transformFromClaude( + claudeContent: string, + model: string, + stopReason?: string +): Record { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + content: claudeContent, + }, + finish_reason: stopReason === "end_turn" ? "stop" : null, + logprobs: null, + }, + ], + }; +} diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index c4b2047f2a..07bece15c0 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -22,6 +22,7 @@ import { type ProviderCredentials, } from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { getProviderPluginManifestHeader } from "../config/providerPluginManifestUrl.ts"; import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; @@ -265,6 +266,7 @@ export class CliproxyapiExecutor extends BaseExecutor { const headers: Record = { "Content-Type": "application/json", + ...getProviderPluginManifestHeader(), }; if (key) { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 12af8ae216..6bca470eb5 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -37,6 +37,17 @@ import { CORS_HEADERS } from "../utils/cors.ts"; import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { createRequire } from "module"; +// Quota parsing/scheduling extracted to a pure leaf; re-exported for external +// importers (handlers/chatCore/codexQuota.ts + tests). +export { + type CodexQuotaSnapshot, + parseCodexQuotaHeaders, + getCodexResetTime, + getCodexDualWindowCooldownMs, +} from "./codex/quota.ts"; +import { isCodexFreePlan, normalizeCodexTools } from "./codex/tools.ts"; +// Re-exported for external importers (tests + provider services). +export { isCodexFreePlan, normalizeCodexTools } from "./codex/tools.ts"; // ─── wreq-js lazy loader ─────────────────────────────────────────────────── // wreq-js is a Rust-native module that requires platform-specific .node binaries. @@ -104,119 +115,6 @@ function codexWebSocketUnavailableResponse(): Response { // Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex) export { getCodexModelScope, getCodexRateLimitKey, type CodexQuotaScope }; -/** - * T03: Parsed quota snapshot from Codex response headers. - * Codex includes per-account usage windows that allow precise reset scheduling. - * Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown) - */ -export interface CodexQuotaSnapshot { - usage5h: number; // tokens used in 5h window - limit5h: number; // token limit for 5h window - resetAt5h: string | null; // ISO timestamp when 5h window resets - usage7d: number; // tokens used in 7d window - limit7d: number; // token limit for 7d window - resetAt7d: string | null; // ISO timestamp when 7d window resets -} - -/** - * T03: Parse Codex-specific quota headers from a provider response. - * Returns null if none of the relevant headers are present. - * - * Extracts: - * x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at - * x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at - */ -export function parseCodexQuotaHeaders(headers: Record): CodexQuotaSnapshot | null { - const usage5h = headers["x-codex-5h-usage"] ?? null; - const limit5h = headers["x-codex-5h-limit"] ?? null; - const resetAt5h = headers["x-codex-5h-reset-at"] ?? null; - const usage7d = headers["x-codex-7d-usage"] ?? null; - const limit7d = headers["x-codex-7d-limit"] ?? null; - const resetAt7d = headers["x-codex-7d-reset-at"] ?? null; - - // Return null if none of the quota headers are present (not a quota-aware response) - if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) { - return null; - } - - return { - usage5h: usage5h ? parseFloat(usage5h) : 0, - limit5h: limit5h ? parseFloat(limit5h) : Infinity, - resetAt5h: resetAt5h ?? null, - usage7d: usage7d ? parseFloat(usage7d) : 0, - limit7d: limit7d ? parseFloat(limit7d) : Infinity, - resetAt7d: resetAt7d ?? null, - }; -} - -/** - * T03: Get the soonest quota reset time from a CodexQuotaSnapshot. - * 7d window takes priority (wider window, harder limit) but we use whichever - * is further in the future to avoid releasing the block too early. - * - * @returns Unix timestamp (ms) of the soonest effective reset, or null - */ -export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null { - const times: number[] = []; - if (quota.resetAt7d) { - const t = new Date(quota.resetAt7d).getTime(); - if (!isNaN(t) && t > Date.now()) times.push(t); - } - if (quota.resetAt5h) { - const t = new Date(quota.resetAt5h).getTime(); - if (!isNaN(t) && t > Date.now()) times.push(t); - } - if (times.length === 0) return null; - return Math.max(...times); // Use furthest-out reset to avoid premature unblock -} - -/** - * T03 (Item 3): Compute the minimum-necessary cooldown based on which window - * is actually exhausted. Prevents over-blocking the account: - * - * - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted) - * - If 5h window >= threshold: cooldown until 5h reset only (short-term limit) - * - Otherwise: 0 (account is healthy, no cooldown needed) - * - * Called after parsing quota headers from a successful/429 response to - * mark the account accordingly without overly long cooldowns. - * - * @param quota - Parsed quota snapshot from response headers - * @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95) - * @returns Cooldown duration in milliseconds (0 = no cooldown needed) - */ -export function getCodexDualWindowCooldownMs( - quota: CodexQuotaSnapshot, - threshold = 0.95 -): { cooldownMs: number; window: "7d" | "5h" | "none" } { - const now = Date.now(); - - // Compute per-window usage ratios (0..1) - const ratio7d = - quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0; - const ratio5h = - quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0; - - // 7d window takes priority — if the weekly budget is near-exhausted, - // we must wait until the weekly reset (not just 5h). - if (ratio7d >= threshold && quota.resetAt7d) { - const resetTime = new Date(quota.resetAt7d).getTime(); - if (resetTime > now) { - return { cooldownMs: resetTime - now, window: "7d" }; - } - } - - // 5h window (primary short-term rate limit) - if (ratio5h >= threshold && quota.resetAt5h) { - const resetTime = new Date(quota.resetAt5h).getTime(); - if (resetTime > now) { - return { cooldownMs: resetTime - now, window: "5h" }; - } - } - - return { cooldownMs: 0, window: "none" }; -} - // Ordered list of effort levels from lowest to highest const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const; type EffortLevel = (typeof EFFORT_ORDER)[number]; @@ -401,169 +299,6 @@ function repairMissingCodexFunctionCallOutputs(body: Record): v } } -// Responses-API hosted tool types that OpenAI/Codex executes server-side. -// These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — -// e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or -// `{ type: "namespace", name: "mcp__atlassian__", tools: [...] }` for MCP tool groups. -// Keep them through `normalizeCodexTools` so upstream can execute them. -const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ - "tool_search", - "image_generation", - "web_search", - "web_search_preview", - "file_search", - "computer", - "computer_use_preview", - "code_interpreter", - "mcp", -]); - -// #2980: a free-plan Codex account (workspacePlanType === "free", from the OAuth -// id_token) cannot run the server-side `image_generation` hosted tool. The Codex -// CLI injects it into every Responses request regardless of plan, so it must be -// dropped for free-plan accounts (mirrors CLIProxyAPI's isCodexFreePlanAuth). -export function isCodexFreePlan(providerSpecificData: unknown): boolean { - if (!providerSpecificData || typeof providerSpecificData !== "object") return false; - const plan = (providerSpecificData as { workspacePlanType?: unknown }).workspacePlanType; - return typeof plan === "string" && plan.trim().toLowerCase() === "free"; -} - -export function normalizeCodexTools( - body: Record, - options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean } -): void { - if (!Array.isArray(body.tools)) return; - - const validToolNames = new Set(); - body.tools = body.tools.filter((toolValue) => { - if (!toolValue || typeof toolValue !== "object" || Array.isArray(toolValue)) { - return false; - } - - const tool = toolValue as Record; - const toolType = typeof tool.type === "string" ? tool.type : ""; - - // Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API). - // Codex API supports them natively; register sub-tool names for tool_choice validation. - if (toolType === "namespace") { - if (Array.isArray(tool.tools)) { - for (const st of tool.tools as unknown[]) { - if (st && typeof st === "object" && !Array.isArray(st)) { - const subTool = st as Record; - const name = typeof subTool.name === "string" ? subTool.name.trim().slice(0, 128) : ""; - if (name) validToolNames.add(name); - } - } - } - return true; - } - - // Native Codex clients send Responses API custom tools such as apply_patch as: - // { type: "custom", name, format }. Preserve those only on native passthrough; - // translated/non-native requests can still contain provider-specific "custom" - // shapes that the Codex backend would reject. - if (toolType === "custom" && options?.preserveCustomTools === true) { - const name = typeof tool.name === "string" ? tool.name.trim().slice(0, 128) : ""; - if (!name) return false; - tool.name = name; - validToolNames.add(name); - return true; - } - - if (toolType !== "function") { - const hasFunctionObject = tool.function && typeof tool.function === "object"; - const hasName = typeof tool.name === "string"; - if (!toolType || hasFunctionObject || hasName) { - return false; - } - if (CODEX_HOSTED_TOOL_TYPES.has(toolType)) { - // #2980: drop the CLI-injected image_generation tool for free-plan - // accounts, which can't run it server-side (upstream 400 otherwise). - if (toolType === "image_generation" && options?.dropImageGeneration === true) { - return false; - } - return true; - } - console.debug(`[Codex] dropping unknown hosted tool type: ${toolType}`); - return false; - } - - const rawName = - typeof tool.name === "string" - ? tool.name - : tool.function && - typeof tool.function === "object" && - !Array.isArray(tool.function) && - typeof (tool.function as Record).name === "string" - ? ((tool.function as Record).name as string) - : ""; - const name = rawName.trim(); - if (!name) { - return false; - } - - // Codex Responses API requires function tools in flat Responses format: - // { type: "function", name, description, parameters } - // Some clients/translators send Chat Completions shape: - // { type: "function", function: { name, description, parameters } } - // which upstream rejects with "Missing required parameter: tools[0].name". - // Flatten the nested `function` wrapper into top-level fields (#1914). - const functionObject = - tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) - ? (tool.function as Record) - : null; - const description = - typeof tool.description === "string" - ? tool.description - : typeof functionObject?.description === "string" - ? functionObject.description - : ""; - const parameters = - tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) - ? tool.parameters - : functionObject?.parameters && - typeof functionObject.parameters === "object" && - !Array.isArray(functionObject.parameters) - ? functionObject.parameters - : { type: "object", properties: {} }; - const strict = - typeof tool.strict === "boolean" - ? tool.strict - : typeof functionObject?.strict === "boolean" - ? functionObject.strict - : undefined; - - // Rewrite in-place to Responses format - for (const key of Object.keys(tool)) { - delete tool[key]; - } - tool.type = "function"; - tool.name = name.slice(0, 128); - if (description) tool.description = description; - tool.parameters = parameters; - if (strict !== undefined) tool.strict = strict; - - validToolNames.add(name); - return true; - }); - - if ( - body.tool_choice && - typeof body.tool_choice === "object" && - !Array.isArray(body.tool_choice) - ) { - const toolChoice = body.tool_choice as Record; - if (toolChoice.type === "function") { - const rawName = typeof toolChoice.name === "string" ? toolChoice.name.trim() : ""; - if (!rawName || !validToolNames.has(rawName)) { - delete body.tool_choice; - } - } else if (toolChoice.type === "local_shell") { - delete body.tool_choice; - } - } -} - function getResponsesSubpath(endpointPath: unknown): string | null { let normalizedEndpoint = String(endpointPath || ""); while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) { @@ -1159,9 +894,7 @@ export class CodexExecutor extends BaseExecutor { headers["chatgpt-account-id"] = workspaceId; } const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as - | CodexClientIdentity - | null - | undefined; + CodexClientIdentity | null | undefined; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" @@ -1481,9 +1214,7 @@ export class CodexExecutor extends BaseExecutor { applyCodexClientMetadata( body, credentials?.providerSpecificData?.codexClientIdentity as - | CodexClientIdentity - | null - | undefined + CodexClientIdentity | null | undefined ); } diff --git a/open-sse/executors/codex/quota.ts b/open-sse/executors/codex/quota.ts new file mode 100644 index 0000000000..977f794dde --- /dev/null +++ b/open-sse/executors/codex/quota.ts @@ -0,0 +1,114 @@ +// Codex quota-snapshot parsing + reset/cooldown scheduling (pure). Verbatim from codex.ts. + +/** + * T03: Parsed quota snapshot from Codex response headers. + * Codex includes per-account usage windows that allow precise reset scheduling. + * Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown) + */ +export interface CodexQuotaSnapshot { + usage5h: number; // tokens used in 5h window + limit5h: number; // token limit for 5h window + resetAt5h: string | null; // ISO timestamp when 5h window resets + usage7d: number; // tokens used in 7d window + limit7d: number; // token limit for 7d window + resetAt7d: string | null; // ISO timestamp when 7d window resets +} + +/** + * T03: Parse Codex-specific quota headers from a provider response. + * Returns null if none of the relevant headers are present. + * + * Extracts: + * x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at + * x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at + */ +export function parseCodexQuotaHeaders(headers: Record): CodexQuotaSnapshot | null { + const usage5h = headers["x-codex-5h-usage"] ?? null; + const limit5h = headers["x-codex-5h-limit"] ?? null; + const resetAt5h = headers["x-codex-5h-reset-at"] ?? null; + const usage7d = headers["x-codex-7d-usage"] ?? null; + const limit7d = headers["x-codex-7d-limit"] ?? null; + const resetAt7d = headers["x-codex-7d-reset-at"] ?? null; + + // Return null if none of the quota headers are present (not a quota-aware response) + if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) { + return null; + } + + return { + usage5h: usage5h ? parseFloat(usage5h) : 0, + limit5h: limit5h ? parseFloat(limit5h) : Infinity, + resetAt5h: resetAt5h ?? null, + usage7d: usage7d ? parseFloat(usage7d) : 0, + limit7d: limit7d ? parseFloat(limit7d) : Infinity, + resetAt7d: resetAt7d ?? null, + }; +} + +/** + * T03: Get the soonest quota reset time from a CodexQuotaSnapshot. + * 7d window takes priority (wider window, harder limit) but we use whichever + * is further in the future to avoid releasing the block too early. + * + * @returns Unix timestamp (ms) of the soonest effective reset, or null + */ +export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null { + const times: number[] = []; + if (quota.resetAt7d) { + const t = new Date(quota.resetAt7d).getTime(); + if (!isNaN(t) && t > Date.now()) times.push(t); + } + if (quota.resetAt5h) { + const t = new Date(quota.resetAt5h).getTime(); + if (!isNaN(t) && t > Date.now()) times.push(t); + } + if (times.length === 0) return null; + return Math.max(...times); // Use furthest-out reset to avoid premature unblock +} + +/** + * T03 (Item 3): Compute the minimum-necessary cooldown based on which window + * is actually exhausted. Prevents over-blocking the account: + * + * - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted) + * - If 5h window >= threshold: cooldown until 5h reset only (short-term limit) + * - Otherwise: 0 (account is healthy, no cooldown needed) + * + * Called after parsing quota headers from a successful/429 response to + * mark the account accordingly without overly long cooldowns. + * + * @param quota - Parsed quota snapshot from response headers + * @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95) + * @returns Cooldown duration in milliseconds (0 = no cooldown needed) + */ +export function getCodexDualWindowCooldownMs( + quota: CodexQuotaSnapshot, + threshold = 0.95 +): { cooldownMs: number; window: "7d" | "5h" | "none" } { + const now = Date.now(); + + // Compute per-window usage ratios (0..1) + const ratio7d = + quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0; + const ratio5h = + quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0; + + // 7d window takes priority — if the weekly budget is near-exhausted, + // we must wait until the weekly reset (not just 5h). + if (ratio7d >= threshold && quota.resetAt7d) { + const resetTime = new Date(quota.resetAt7d).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "7d" }; + } + } + + // 5h window (primary short-term rate limit) + if (ratio5h >= threshold && quota.resetAt5h) { + const resetTime = new Date(quota.resetAt5h).getTime(); + if (resetTime > now) { + return { cooldownMs: resetTime - now, window: "5h" }; + } + } + + return { cooldownMs: 0, window: "none" }; +} diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts new file mode 100644 index 0000000000..3337000359 --- /dev/null +++ b/open-sse/executors/codex/tools.ts @@ -0,0 +1,165 @@ +// Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating). +// Extracted verbatim from codex.ts. Self-contained (console.debug only). + +// Responses-API hosted tool types that OpenAI/Codex executes server-side. +// These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — +// e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or +// `{ type: "namespace", name: "mcp__atlassian__", tools: [...] }` for MCP tool groups. +// Keep them through `normalizeCodexTools` so upstream can execute them. +export const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ + "tool_search", + "image_generation", + "web_search", + "web_search_preview", + "file_search", + "computer", + "computer_use_preview", + "code_interpreter", + "mcp", +]); + +// #2980: a free-plan Codex account (workspacePlanType === "free", from the OAuth +// id_token) cannot run the server-side `image_generation` hosted tool. The Codex +// CLI injects it into every Responses request regardless of plan, so it must be +// dropped for free-plan accounts (mirrors CLIProxyAPI's isCodexFreePlanAuth). +export function isCodexFreePlan(providerSpecificData: unknown): boolean { + if (!providerSpecificData || typeof providerSpecificData !== "object") return false; + const plan = (providerSpecificData as { workspacePlanType?: unknown }).workspacePlanType; + return typeof plan === "string" && plan.trim().toLowerCase() === "free"; +} + +export function normalizeCodexTools( + body: Record, + options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean } +): void { + if (!Array.isArray(body.tools)) return; + + const validToolNames = new Set(); + body.tools = body.tools.filter((toolValue) => { + if (!toolValue || typeof toolValue !== "object" || Array.isArray(toolValue)) { + return false; + } + + const tool = toolValue as Record; + const toolType = typeof tool.type === "string" ? tool.type : ""; + + // Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API). + // Codex API supports them natively; register sub-tool names for tool_choice validation. + if (toolType === "namespace") { + if (Array.isArray(tool.tools)) { + for (const st of tool.tools as unknown[]) { + if (st && typeof st === "object" && !Array.isArray(st)) { + const subTool = st as Record; + const name = typeof subTool.name === "string" ? subTool.name.trim().slice(0, 128) : ""; + if (name) validToolNames.add(name); + } + } + } + return true; + } + + // Native Codex clients send Responses API custom tools such as apply_patch as: + // { type: "custom", name, format }. Preserve those only on native passthrough; + // translated/non-native requests can still contain provider-specific "custom" + // shapes that the Codex backend would reject. + if (toolType === "custom" && options?.preserveCustomTools === true) { + const name = typeof tool.name === "string" ? tool.name.trim().slice(0, 128) : ""; + if (!name) return false; + tool.name = name; + validToolNames.add(name); + return true; + } + + if (toolType !== "function") { + const hasFunctionObject = tool.function && typeof tool.function === "object"; + const hasName = typeof tool.name === "string"; + if (!toolType || hasFunctionObject || hasName) { + return false; + } + if (CODEX_HOSTED_TOOL_TYPES.has(toolType)) { + // #2980: drop the CLI-injected image_generation tool for free-plan + // accounts, which can't run it server-side (upstream 400 otherwise). + if (toolType === "image_generation" && options?.dropImageGeneration === true) { + return false; + } + return true; + } + console.debug(`[Codex] dropping unknown hosted tool type: ${toolType}`); + return false; + } + + const rawName = + typeof tool.name === "string" + ? tool.name + : tool.function && + typeof tool.function === "object" && + !Array.isArray(tool.function) && + typeof (tool.function as Record).name === "string" + ? ((tool.function as Record).name as string) + : ""; + const name = rawName.trim(); + if (!name) { + return false; + } + + // Codex Responses API requires function tools in flat Responses format: + // { type: "function", name, description, parameters } + // Some clients/translators send Chat Completions shape: + // { type: "function", function: { name, description, parameters } } + // which upstream rejects with "Missing required parameter: tools[0].name". + // Flatten the nested `function` wrapper into top-level fields (#1914). + const functionObject = + tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? (tool.function as Record) + : null; + const description = + typeof tool.description === "string" + ? tool.description + : typeof functionObject?.description === "string" + ? functionObject.description + : ""; + const parameters = + tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? tool.parameters + : functionObject?.parameters && + typeof functionObject.parameters === "object" && + !Array.isArray(functionObject.parameters) + ? functionObject.parameters + : { type: "object", properties: {} }; + const strict = + typeof tool.strict === "boolean" + ? tool.strict + : typeof functionObject?.strict === "boolean" + ? functionObject.strict + : undefined; + + // Rewrite in-place to Responses format + for (const key of Object.keys(tool)) { + delete tool[key]; + } + tool.type = "function"; + tool.name = name.slice(0, 128); + if (description) tool.description = description; + tool.parameters = parameters; + if (strict !== undefined) tool.strict = strict; + + validToolNames.add(name); + return true; + }); + + if ( + body.tool_choice && + typeof body.tool_choice === "object" && + !Array.isArray(body.tool_choice) + ) { + const toolChoice = body.tool_choice as Record; + if (toolChoice.type === "function") { + const rawName = typeof toolChoice.name === "string" ? toolChoice.name.trim() : ""; + if (!rawName || !validToolNames.has(rawName)) { + delete body.tool_choice; + } + } else if (toolChoice.type === "local_shell") { + delete body.tool_choice; + } + } +} diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 62cf10f348..ad7a9b68a6 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -38,11 +38,7 @@ import { type McpToolDefinition, type OpenAITool, } from "../utils/cursorAgentProtobuf.ts"; -import { - resolveCursorImages, - extractImageUrls, - CursorImageError, -} from "../utils/cursorImages.ts"; +import { resolveCursorImages, extractImageUrls, CursorImageError } from "../utils/cursorImages.ts"; import { estimateInputTokens, estimateOutputTokens, @@ -62,6 +58,18 @@ import crypto from "crypto"; import * as fs from "node:fs"; import * as zlib from "node:zlib"; import { promisify } from "node:util"; +import { toolChoiceDirectiveLine, buildCursorOutputConstraints } from "./cursor/prompt.ts"; +import { + isComposerModel, + visibleComposerContentFromThinking, + composerReasoningRemainder, +} from "./cursor/composer.ts"; +// Composer helpers re-exported for external importers (tests). +export { + isComposerModel, + visibleComposerContentFromThinking, + composerReasoningRemainder, +} from "./cursor/composer.ts"; // Reject reason text aligned with kaitranntt/CLIProxyAPIPlus — proven to // keep cursor's model from retrying the same built-in tool indefinitely. @@ -89,78 +97,6 @@ const TOOL_COMMIT_DIRECTIVE = [ // non-existent switch_mode tool and measurably LOWERED the tool-call rate in // live A/B (56% vs 69%), so it is intentionally not ported. -function isRecordLike(v: unknown): v is Record { - return typeof v === "object" && v !== null; -} - -/** - * Translate OpenAI `tool_choice` into an extra directive line — cursor's agent - * endpoint has no native equivalent. `"required"` forces some tool; a specific - * `{type:"function", function:{name}}` forces that tool. `"auto"`/`"none"`/ - * absent add nothing here ("none" is handled by dropping tools entirely). - * Ported from composer-api (directToolChoiceHint / tool_choice === "required"). - */ -function toolChoiceDirectiveLine(toolChoice: unknown): string { - if (toolChoice === "required") { - return "\nYou MUST call at least one of the available tools now; do not answer without calling a tool."; - } - if ( - isRecordLike(toolChoice) && - toolChoice.type === "function" && - isRecordLike(toolChoice.function) && - typeof toolChoice.function.name === "string" && - toolChoice.function.name - ) { - return `\nYou MUST call the \`${toolChoice.function.name}\` tool now and not any other tool.`; - } - return ""; -} - -/** - * Build an OUTPUT CONSTRAINTS block from OpenAI request params that cursor's - * agent endpoint silently ignores (response_format / max_tokens / stop), so - * they're surfaced to the model as prompt instructions instead. Ported from - * composer-api (appendChatOptions / appendJsonConstraint / appendStopConstraint). - * Returns "" when no constraints apply. - */ -function buildCursorOutputConstraints(body: { - max_tokens?: unknown; - max_completion_tokens?: unknown; - stop?: unknown; - response_format?: unknown; -}): string { - const constraints: string[] = []; - - const rawMax = body.max_completion_tokens ?? body.max_tokens; - const maxTokens = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.floor(rawMax) : 0; - if (maxTokens > 0) { - constraints.push(`Keep the answer within about ${maxTokens} output tokens.`); - } - - const stop = body.stop; - if (typeof stop === "string" && stop) { - constraints.push(`Do not include any text at or after this stop sequence: ${stop}`); - } else if (Array.isArray(stop) && stop.length) { - constraints.push(`Stop before any of these sequences: ${stop.filter(Boolean).join(", ")}`); - } - - const fmt = body.response_format; - if (isRecordLike(fmt)) { - if (fmt.type === "json_object") { - constraints.push("Return a single valid JSON object and no surrounding prose or code fences."); - } else if (fmt.type === "json_schema") { - const js = isRecordLike(fmt.json_schema) ? fmt.json_schema.schema : fmt.schema; - constraints.push( - `Return only valid JSON (no prose or code fences) matching this schema: ${JSON.stringify(js ?? fmt)}` - ); - } - } - - return constraints.length - ? `\n\nOUTPUT CONSTRAINTS:\n${constraints.map((c) => `- ${c}`).join("\n")}` - : ""; -} - /** * Build the ExecClientMessage frame that responds to a built-in tool request. * Returns null for the request_context handshake (caller handles separately @@ -311,61 +247,6 @@ function tryParseJsonError(payload: Buffer): { message: string; status: number } } } -// ─── Composer thinking-as-content decoding ───────────────────────────────── -// -// The Cursor `composer-*` family encodes its visible reply inside the -// `thinking` field, marked off from the (private) chain-of-thought by a -// final `` sentinel. Everything AFTER the last `` is the -// user-facing reply; the prefix must stay hidden. -// -// Ported from decolua/9router#1310 by Noé Rivera. Same algorithm, adapted -// to OmniRoute's StreamCtx-based pipeline so streaming + non-streaming -// share the accumulation path. - -const COMPOSER_THINK_END = ""; - -export function isComposerModel(model: string | undefined | null): boolean { - const id = String(model ?? "") - .split("/") - .pop(); - return /^composer(?:-|$)/i.test(id ?? ""); -} - -// Composer's protobuf sometimes wraps the visible suffix in sentinel tags: -// `<|final|>` (full-width pipes) or `<|final|>` (ASCII), optionally closed -// with a matching `<|/final|>` / `<|/final|>`. These are protocol-internal -// and must never leak to OpenAI-compatible clients (decolua/9router#1316). -const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; -const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; -const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; -const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; - -export function visibleComposerContentFromThinking(thinking: string): string { - if (!thinking) return ""; - const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); - if (endIdx < 0) return ""; - let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); - if (COMPOSER_OPEN_MARKER.test(visible)) { - visible = visible.replace(COMPOSER_OPEN_MARKER, ""); - } else if ( - COMPOSER_PARTIAL_OPEN.test(visible) || - COMPOSER_PARTIAL_OPEN_PIPE.test(visible) - ) { - // A streamed chunk delivered only a partial opening marker (e.g. `<` or - // `<|fin`). Hold back everything until more data arrives so the marker - // fragment never leaks as content. - return ""; - } - return visible.replace(COMPOSER_CLOSE_MARKER, "").trim(); -} - -export function composerReasoningRemainder(thinking: string): string { - if (!thinking) return ""; - const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); - if (endIdx < 0) return thinking; - return thinking.slice(0, endIdx); -} - // ─── Phase 4: streaming dispatch context ─────────────────────────────────── // // One StreamCtx flows through a single execute() call. It owns the live @@ -676,11 +557,19 @@ export function processFrame( ctx.totalText += parseOut.safeDelta; emitChunk(ctx, { content: parseOut.safeDelta }); } - if (parseOut.ready && parseOut.toolCalls.length > 0 && !ctx.composerInlineToolCallsEmitted) { + if ( + parseOut.ready && + parseOut.toolCalls.length > 0 && + !ctx.composerInlineToolCallsEmitted + ) { ctx.composerInlineToolCallsEmitted = true; for (const tc of parseOut.toolCalls) { const toolCallIndex = ctx.emittedToolCallIndex++; - ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); emitChunk(ctx, { tool_calls: [ { @@ -1435,11 +1324,7 @@ export class CursorExecutor extends BaseExecutor { // parser state never reached "ready"), try a full non-streaming parse on // the accumulated visible content so we still emit structured tool_calls // and don't leak the markers as plain text. - if ( - isComposerModel(ctx.model) && - !ctx.composerInlineToolCallsEmitted && - ctx.totalText - ) { + if (isComposerModel(ctx.model) && !ctx.composerInlineToolCallsEmitted && ctx.totalText) { const parsed = parseComposerToolCalls(ctx.totalText); if (parsed.toolCalls.length > 0) { ctx.composerInlineToolCallsEmitted = true; @@ -1447,7 +1332,11 @@ export class CursorExecutor extends BaseExecutor { ctx.totalText = parsed.content; for (const tc of parsed.toolCalls) { const toolCallIndex = ctx.emittedToolCallIndex++; - ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); emitChunk(ctx, { tool_calls: [ { @@ -1501,17 +1390,17 @@ export class CursorExecutor extends BaseExecutor { // Composer DeepSeek inline tool-call fallback (decolua/9router#1335): for // non-streaming requests, the streaming parser never runs — parse the // accumulated visible content once here instead. - if ( - isComposerModel(ctx.model) && - !ctx.composerInlineToolCallsEmitted && - ctx.totalText - ) { + if (isComposerModel(ctx.model) && !ctx.composerInlineToolCallsEmitted && ctx.totalText) { const parsed = parseComposerToolCalls(ctx.totalText); if (parsed.toolCalls.length > 0) { ctx.composerInlineToolCallsEmitted = true; ctx.totalText = parsed.content; for (const tc of parsed.toolCalls) { - ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + ctx.toolCalls.push({ + id: tc.id, + name: tc.function.name, + argumentsJson: tc.function.arguments, + }); } } } diff --git a/open-sse/executors/cursor/composer.ts b/open-sse/executors/cursor/composer.ts new file mode 100644 index 0000000000..73d590992b --- /dev/null +++ b/open-sse/executors/cursor/composer.ts @@ -0,0 +1,53 @@ +// Composer thinking-as-content decoding for the Cursor executor (verbatim, no host imports). + +// ─── Composer thinking-as-content decoding ───────────────────────────────── +// +// The Cursor `composer-*` family encodes its visible reply inside the +// `thinking` field, marked off from the (private) chain-of-thought by a +// final `` sentinel. Everything AFTER the last `` is the +// user-facing reply; the prefix must stay hidden. +// +// Ported from decolua/9router#1310 by Noé Rivera. Same algorithm, adapted +// to OmniRoute's StreamCtx-based pipeline so streaming + non-streaming +// share the accumulation path. + +const COMPOSER_THINK_END = ""; + +export function isComposerModel(model: string | undefined | null): boolean { + const id = String(model ?? "") + .split("/") + .pop(); + return /^composer(?:-|$)/i.test(id ?? ""); +} + +// Composer's protobuf sometimes wraps the visible suffix in sentinel tags: +// `<|final|>` (full-width pipes) or `<|final|>` (ASCII), optionally closed +// with a matching `<|/final|>` / `<|/final|>`. These are protocol-internal +// and must never leak to OpenAI-compatible clients (decolua/9router#1316). +const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; +const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; +const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; +const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; + +export function visibleComposerContentFromThinking(thinking: string): string { + if (!thinking) return ""; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return ""; + let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); + if (COMPOSER_OPEN_MARKER.test(visible)) { + visible = visible.replace(COMPOSER_OPEN_MARKER, ""); + } else if (COMPOSER_PARTIAL_OPEN.test(visible) || COMPOSER_PARTIAL_OPEN_PIPE.test(visible)) { + // A streamed chunk delivered only a partial opening marker (e.g. `<` or + // `<|fin`). Hold back everything until more data arrives so the marker + // fragment never leaks as content. + return ""; + } + return visible.replace(COMPOSER_CLOSE_MARKER, "").trim(); +} + +export function composerReasoningRemainder(thinking: string): string { + if (!thinking) return ""; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return thinking; + return thinking.slice(0, endIdx); +} diff --git a/open-sse/executors/cursor/prompt.ts b/open-sse/executors/cursor/prompt.ts new file mode 100644 index 0000000000..ae93452374 --- /dev/null +++ b/open-sse/executors/cursor/prompt.ts @@ -0,0 +1,75 @@ +// Pure prompt/constraint builders for the Cursor executor (verbatim, no host imports). + +export function isRecordLike(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +/** + * Translate OpenAI `tool_choice` into an extra directive line — cursor's agent + * endpoint has no native equivalent. `"required"` forces some tool; a specific + * `{type:"function", function:{name}}` forces that tool. `"auto"`/`"none"`/ + * absent add nothing here ("none" is handled by dropping tools entirely). + * Ported from composer-api (directToolChoiceHint / tool_choice === "required"). + */ +export function toolChoiceDirectiveLine(toolChoice: unknown): string { + if (toolChoice === "required") { + return "\nYou MUST call at least one of the available tools now; do not answer without calling a tool."; + } + if ( + isRecordLike(toolChoice) && + toolChoice.type === "function" && + isRecordLike(toolChoice.function) && + typeof toolChoice.function.name === "string" && + toolChoice.function.name + ) { + return `\nYou MUST call the \`${toolChoice.function.name}\` tool now and not any other tool.`; + } + return ""; +} + +/** + * Build an OUTPUT CONSTRAINTS block from OpenAI request params that cursor's + * agent endpoint silently ignores (response_format / max_tokens / stop), so + * they're surfaced to the model as prompt instructions instead. Ported from + * composer-api (appendChatOptions / appendJsonConstraint / appendStopConstraint). + * Returns "" when no constraints apply. + */ +export function buildCursorOutputConstraints(body: { + max_tokens?: unknown; + max_completion_tokens?: unknown; + stop?: unknown; + response_format?: unknown; +}): string { + const constraints: string[] = []; + + const rawMax = body.max_completion_tokens ?? body.max_tokens; + const maxTokens = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.floor(rawMax) : 0; + if (maxTokens > 0) { + constraints.push(`Keep the answer within about ${maxTokens} output tokens.`); + } + + const stop = body.stop; + if (typeof stop === "string" && stop) { + constraints.push(`Do not include any text at or after this stop sequence: ${stop}`); + } else if (Array.isArray(stop) && stop.length) { + constraints.push(`Stop before any of these sequences: ${stop.filter(Boolean).join(", ")}`); + } + + const fmt = body.response_format; + if (isRecordLike(fmt)) { + if (fmt.type === "json_object") { + constraints.push( + "Return a single valid JSON object and no surrounding prose or code fences." + ); + } else if (fmt.type === "json_schema") { + const js = isRecordLike(fmt.json_schema) ? fmt.json_schema.schema : fmt.schema; + constraints.push( + `Return only valid JSON (no prose or code fences) matching this schema: ${JSON.stringify(js ?? fmt)}` + ); + } + } + + return constraints.length + ? `\n\nOUTPUT CONSTRAINTS:\n${constraints.map((c) => `- ${c}`).join("\n")}` + : ""; +} diff --git a/open-sse/executors/deepseek-web-with-auto-refresh.ts b/open-sse/executors/deepseek-web-with-auto-refresh.ts index 167823902d..bd65532947 100644 --- a/open-sse/executors/deepseek-web-with-auto-refresh.ts +++ b/open-sse/executors/deepseek-web-with-auto-refresh.ts @@ -125,24 +125,53 @@ export class DeepSeekWebWithAutoRefreshExecutor extends DeepSeekWebExecutor { return super.execute(input); } + /** + * Refresh the session once and re-run the base executor. Returns the retried + * result, or `null` when the refresh itself fails (dead userToken) so the + * caller surfaces the original failure instead of looping. + */ + private async refreshAndRetry( + input: ExecuteInput + ): Promise> | null> { + this.retryCount++; + try { + await this.doRefreshSession(); + return await this.executeBase(input); + } catch (refreshError) { + console.error( + `[DeepSeek-WEB] Session refresh failed (attempt ${this.retryCount}/${this.maxRetries}):`, + refreshError + ); + return null; + } + } + private async executeWithRetry(input: ExecuteInput) { try { - return await this.executeBase(input); + const result = await this.executeBase(input); + + // The base DeepSeekWebExecutor never throws on an upstream auth failure — + // it converts 401/403 into a returned error Response (and deletes the stale + // access token). Detect that here so auto-refresh actually fires; relying + // only on the thrown-error catch below left the retry path dead, so a stale + // access token surfaced a 401 to the client on every refresh boundary + // instead of self-healing. + const status = result?.response?.status; + if ((status === 401 || status === 403) && this.retryCount < this.maxRetries) { + const retried = await this.refreshAndRetry(input); + return retried ?? result; + } + + return result; } catch (error: unknown) { + // Defensive: keep handling genuinely thrown failures (e.g. a network error + // that escapes the base) even though the base normally returns Responses. const msg = error instanceof Error ? error.message : String(error); const isUnauthorized = msg.includes("401") || msg.includes("Unauthorized") || msg.includes("expired"); if (isUnauthorized && this.retryCount < this.maxRetries) { - this.retryCount++; - try { - await this.doRefreshSession(); - return await this.executeBase(input); - } catch (refreshError) { - console.error( - `[DeepSeek-WEB] Session refresh failed (attempt ${this.retryCount}/${this.maxRetries}):`, - refreshError - ); - } + const retried = await this.refreshAndRetry(input); + if (retried) return retried; } if (msg.includes("429") || msg.includes("Rate limit")) { console.warn("[DeepSeek-WEB] Rate limited:", msg); diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index bdc6ad1a15..7b6e5e0167 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -7,11 +7,27 @@ import { buildToolConversationPrompt, } from "../translator/deepseekWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + isThinkingModel, + isSearchModel, + formatStreamContent, + appendSearchCitations, + type DeepSeekSearchResult, +} from "./deepseek-web/stream-format.ts"; export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com"; const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`; const COMPLETION_URL = `${DEEPSEEK_API_BASE}/v0/chat/completion`; +// Fingerprint headers the chat.deepseek.com web client sends on every /api/v0/* +// request. Kept in sync with a real captured completion request (client v2.0.0): +// the 2.0.0 web build DROPPED the legacy `X-App-Version` build stamp and ADDED +// `X-Client-Bundle-Id`. Sending the stale `X-App-Version` (and the old 1.8.0 +// version) is itself a bot-detection signal, so match the current client exactly. +// NOTE: the live client also sends `x-hif-leim`, a signed client-attestation +// token generated by obfuscated JS. It is intentionally omitted — reproducing it +// requires porting that JS, and it is not currently enforced by the completion +// endpoint. Revisit if requests start failing a client-attestation check. const FAKE_HEADERS: Record = { Accept: "*/*", "Accept-Encoding": "gzip, deflate, br, zstd", @@ -20,10 +36,10 @@ const FAKE_HEADERS: Record = { Referer: `${DEEPSEEK_WEB_BASE}/`, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", - "X-App-Version": "20241129.1", + "X-Client-Bundle-Id": "com.deepseek.chat", "X-Client-Locale": "en-US", "X-Client-Platform": "web", - "X-Client-Version": "1.8.0", + "X-Client-Version": "2.0.0", }; // ── Types ──────────────────────────────────────────────────────────────── @@ -144,46 +160,6 @@ async function solvePow(challenge: PowChallenge): Promise { // ── SSE Transform (DeepSeek → OpenAI) ─────────────────────────────────── -function isThinkingModel(model: string): boolean { - const m = model.toLowerCase(); - return m.includes("think") || m.includes("r1") || m.includes("reason"); -} - -function isSearchModel(model: string): boolean { - const m = model.toLowerCase(); - return m.includes("search") || m.includes("fold"); -} - -function cleanDeepSeekToken(text: string): string { - return text.replace(/FINISHED/g, "").replace(/^(SEARCH|WEB_SEARCH|SEARCHING)\s*/i, ""); -} - -function formatStreamContent(raw: string, model: string): string { - let text = cleanDeepSeekToken(raw); - if (!isSearchModel(model)) return text; - if (model.toLowerCase().includes("search-silent")) { - return text.replace(/\[citation:(\d+)\]/g, ""); - } - return text.replace(/\[citation:(\d+)\]/g, "[$1]"); -} - -interface DeepSeekSearchResult { - cite_index?: number; - title?: string; - url?: string; -} - -function appendSearchCitations(searchResults: DeepSeekSearchResult[], model: string): string { - if (searchResults.length === 0 || model.toLowerCase().includes("search-silent")) { - return ""; - } - return searchResults - .filter((r) => r.cite_index) - .sort((a, b) => (a.cite_index || 0) - (b.cite_index || 0)) - .map((r) => `[${r.cite_index}]: [${r.title}](${r.url})`) - .join("\n"); -} - function transformSSE(deepseekStream: ReadableStream, model: string): ReadableStream { const encoder = new TextEncoder(); const decoder = new TextDecoder(); diff --git a/open-sse/executors/deepseek-web/stream-format.ts b/open-sse/executors/deepseek-web/stream-format.ts new file mode 100644 index 0000000000..44a4e5232b --- /dev/null +++ b/open-sse/executors/deepseek-web/stream-format.ts @@ -0,0 +1,44 @@ +// Pure DeepSeek stream content/citation formatting. Verbatim from deepseek-web.ts. + +export function isThinkingModel(model: string): boolean { + const m = model.toLowerCase(); + return m.includes("think") || m.includes("r1") || m.includes("reason"); +} + +export function isSearchModel(model: string): boolean { + const m = model.toLowerCase(); + return m.includes("search") || m.includes("fold"); +} + +export function cleanDeepSeekToken(text: string): string { + return text.replace(/FINISHED/g, "").replace(/^(SEARCH|WEB_SEARCH|SEARCHING)\s*/i, ""); +} + +export function formatStreamContent(raw: string, model: string): string { + let text = cleanDeepSeekToken(raw); + if (!isSearchModel(model)) return text; + if (model.toLowerCase().includes("search-silent")) { + return text.replace(/\[citation:(\d+)\]/g, ""); + } + return text.replace(/\[citation:(\d+)\]/g, "[$1]"); +} + +export interface DeepSeekSearchResult { + cite_index?: number; + title?: string; + url?: string; +} + +export function appendSearchCitations( + searchResults: DeepSeekSearchResult[], + model: string +): string { + if (searchResults.length === 0 || model.toLowerCase().includes("search-silent")) { + return ""; + } + return searchResults + .filter((r) => r.cite_index) + .sort((a, b) => (a.cite_index || 0) - (b.cite_index || 0)) + .map((r) => `[${r.cite_index}]: [${r.title}](${r.url})`) + .join("\n"); +} diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index cbdb8d90bf..afabd75645 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -24,17 +24,23 @@ import { isClaudeCodeCompatible, } from "../services/provider.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; -import { buildDataRobotChatUrl } from "../config/datarobot.ts"; -import { buildAzureAiChatUrl } from "../config/azureAi.ts"; -import { buildWatsonxChatUrl } from "../config/watsonx.ts"; -import { buildOciChatUrl } from "../config/oci.ts"; -import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts"; +import { getSapResourceGroup } from "../config/sap.ts"; +import { + normalizeBailianMessagesUrl, + normalizeDataRobotChatUrl, + normalizeAzureAiChatUrl, + normalizeWatsonxChatUrl, + normalizeOciChatUrl, + normalizeSapChatUrl, + normalizeXiaomiMimoChatUrl, + normalizeOpenAIChatUrl, + getOpenRouterConnectionPreset, +} from "./default/urlNormalizers.ts"; import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; -import { normalizeBaseUrl } from "../utils/urlSanitize.ts"; import { normalizeHerokuChatUrl, normalizeDatabricksChatUrl, @@ -87,62 +93,6 @@ function applyCustomHeaders(headers: Record, rawCustomHeaders: u } } -function normalizeBailianMessagesUrl(baseUrl) { - const normalized = normalizeBaseUrl(baseUrl).replace(/\?beta=true$/, ""); - const messagesUrl = normalized.endsWith("/messages") ? normalized : `${normalized}/messages`; - return messagesUrl; -} - -function normalizeDataRobotChatUrl(baseUrl) { - return buildDataRobotChatUrl(baseUrl); -} - -function normalizeAzureAiChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { - return buildAzureAiChatUrl(baseUrl, apiType); -} - -function normalizeWatsonxChatUrl(baseUrl: string) { - return buildWatsonxChatUrl(baseUrl); -} - -function normalizeOciChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { - return buildOciChatUrl(baseUrl, apiType); -} - -function normalizeSapChatUrl(baseUrl) { - return buildSapChatUrl(baseUrl); -} - -function normalizeXiaomiMimoChatUrl(baseUrl) { - const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, ""); - return `${normalized}/chat/completions`; -} - -function normalizeOpenAIChatUrl(baseUrl) { - const normalized = normalizeBaseUrl(baseUrl); - if ( - normalized.endsWith("/chat/completions") || - normalized.endsWith("/responses") || - normalized.endsWith("/chat") - ) { - return normalized; - } - if (normalized.endsWith("/v1")) { - return `${normalized}/chat/completions`; - } - // Assume OpenAI-compatible /v1/chat/completions path structure - // when the base URL is a bare hostname or custom path (e.g. llama.cpp, vLLM, LM Studio). - return `${normalized}/v1/chat/completions`; -} - -function getOpenRouterConnectionPreset( - providerSpecificData?: Record | null -): string | null { - const preset = - typeof providerSpecificData?.preset === "string" ? providerSpecificData.preset.trim() : ""; - return preset || null; -} - export class DefaultExecutor extends BaseExecutor { constructor(provider) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); @@ -737,9 +687,54 @@ export class DefaultExecutor extends BaseExecutor { } } + // ClinePass reasoning models burn all of max_tokens on the thinking phase + // when the budget is too small, leaving content empty (finish_reason: + // "length"). Bump max_tokens to a safe floor when reasoning is enabled and + // the budget is undersized. CLINEPASS-GATED — no-op for every other provider. + if (typeof withDefaults === "object" && withDefaults !== null) { + this.ensureThinkingBudget(withDefaults as Record, model); + } + return withDefaults; } + // ClinePass / OpenRouter-style thinking models leave content empty when the + // reasoning budget consumes all of max_tokens. Bump max_tokens to a safe + // minimum only when reasoning is enabled and the budget is undersized. + // CLINEPASS-GATED: returns early for every other provider. + ensureThinkingBudget(body: Record, model: string): Record { + if (!body || this.provider !== "clinepass") return body; + + const outboundModel = typeof body.model === "string" ? body.model : model; + const entry = getRegistryEntry(this.provider); + const modelEntry = entry?.models?.find((m) => m.id === outboundModel); + if (!modelEntry?.supportsReasoning) return body; + + const extraBody = body.extra_body as Record | undefined; + const thinking = extraBody?.thinking as Record | undefined; + const effort = body.reasoning_effort; + const reasoningEnabled = + thinking?.type === "enabled" || + (typeof effort === "string" && effort !== "none" && effort !== "off") || + effort === true; + if (!reasoningEnabled) return body; + + const MIN_TOKENS = 4096; + const maxOutput = + typeof modelEntry.maxOutputTokens === "number" && modelEntry.maxOutputTokens > 0 + ? modelEntry.maxOutputTokens + : MIN_TOKENS; + const target = Math.min(MIN_TOKENS, maxOutput); + const current = body.max_tokens ?? body.max_completion_tokens; + + if (typeof current !== "number" || current <= 0) { + body.max_tokens = target; + } else if (current < MIN_TOKENS && current < maxOutput) { + body.max_tokens = MIN_TOKENS; + } + return body; + } + /** * Refresh credentials via the centralized tokenRefresh service. * Delegates to getAccessToken() which handles all providers with diff --git a/open-sse/executors/default/urlNormalizers.ts b/open-sse/executors/default/urlNormalizers.ts new file mode 100644 index 0000000000..a4a5ed4ae5 --- /dev/null +++ b/open-sse/executors/default/urlNormalizers.ts @@ -0,0 +1,64 @@ +// Pure per-provider chat-URL normalizers + connection-preset reader. +// Extracted verbatim from default.ts (string transforms only, no host state/this). +import { buildDataRobotChatUrl } from "../../config/datarobot.ts"; +import { buildAzureAiChatUrl } from "../../config/azureAi.ts"; +import { buildWatsonxChatUrl } from "../../config/watsonx.ts"; +import { buildOciChatUrl } from "../../config/oci.ts"; +import { buildSapChatUrl } from "../../config/sap.ts"; +import { normalizeBaseUrl } from "../../utils/urlSanitize.ts"; + +export function normalizeBailianMessagesUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl).replace(/\?beta=true$/, ""); + const messagesUrl = normalized.endsWith("/messages") ? normalized : `${normalized}/messages`; + return messagesUrl; +} + +export function normalizeDataRobotChatUrl(baseUrl) { + return buildDataRobotChatUrl(baseUrl); +} + +export function normalizeAzureAiChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { + return buildAzureAiChatUrl(baseUrl, apiType); +} + +export function normalizeWatsonxChatUrl(baseUrl: string) { + return buildWatsonxChatUrl(baseUrl); +} + +export function normalizeOciChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") { + return buildOciChatUrl(baseUrl, apiType); +} + +export function normalizeSapChatUrl(baseUrl) { + return buildSapChatUrl(baseUrl); +} + +export function normalizeXiaomiMimoChatUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, ""); + return `${normalized}/chat/completions`; +} + +export function normalizeOpenAIChatUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + if ( + normalized.endsWith("/chat/completions") || + normalized.endsWith("/responses") || + normalized.endsWith("/chat") + ) { + return normalized; + } + if (normalized.endsWith("/v1")) { + return `${normalized}/chat/completions`; + } + // Assume OpenAI-compatible /v1/chat/completions path structure + // when the base URL is a bare hostname or custom path (e.g. llama.cpp, vLLM, LM Studio). + return `${normalized}/v1/chat/completions`; +} + +export function getOpenRouterConnectionPreset( + providerSpecificData?: Record | null +): string | null { + const preset = + typeof providerSpecificData?.preset === "string" ? providerSpecificData.preset.trim() : ""; + return preset || null; +} diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 05cc4b12cd..e9c6053548 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -1,6 +1,6 @@ -import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import vm from "node:vm"; -import { parseFragment, serialize } from "parse5"; +import { solveDuckDuckGoChallenge, makeDuckDuckGoFeSignals } from "./duckduckgo-web/challenge.ts"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; @@ -82,71 +82,6 @@ type DuckDuckGoChallengeResult = { let durablePublicKey: JsonWebKey | null = null; -const CHALLENGE_STUBS = String.raw` -var __ua = __DDG_REAL_UA__; -var __HTML_LOOKUP = __DDG_HTML_LOOKUP__; -function __makeHtmlElement(tag) { - var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' }; - var el = { - tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1, - children: [], childNodes: [], classList: [], dataset: {}, - offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1, - getBoundingClientRect: function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }, - setAttribute: function(){}, removeAttribute: function(){}, - getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; }, - hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; }, - addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; }, - querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; }, - cloneNode: function(){ return __makeHtmlElement(tag); } - }; - Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true }); - Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true }); - Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + ''; }, enumerable: true }); - Object.defineProperty(el, 'srcdoc', { get: function(){ return state._srcdoc||''; }, set: function(v){ state._srcdoc = String(v); }, enumerable: true }); - Object.defineProperty(el, 'contentWindow', { get: function(){ var w = {}; w.document = __ifDoc; w.Proxy = Proxy; w.self = w; w.top = w; w.parent = w; w.window = w; return w; }, enumerable: true }); - Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true }); - return el; -} -function __mkObj(name, base) { - base = base || {}; - return new Proxy(base, { - get: function(t, k) { - if (k in t) return t[k]; - if (k === Symbol.toPrimitive) return function(){ return ''; }; - if (k === Symbol.iterator) return undefined; - if (k === 'then' || k === 'catch' || k === 'finally') return undefined; - if (k === 'constructor') return Object; - if (k === 'toString' || k === 'valueOf') return function(){ return '[object ' + name + ']'; }; - if (k === 'length') return 0; - if (k === 'nodeType') return 1; - if (k === 'tagName' || k === 'nodeName') return 'DIV'; - if (k === 'innerHTML' || k === 'outerHTML' || k === 'textContent' || k === 'innerText' || k === 'value') return ''; - if (k === 'children' || k === 'childNodes' || k === 'classList') return []; - // Real numeric layout values for the DDG challenge DOM probes. - if (k === 'offsetWidth' || k === 'offsetHeight' || k === 'clientWidth' || k === 'clientHeight' || k === 'scrollHeight' || k === 'scrollWidth') return 1; - if (k === 'getBoundingClientRect') return function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }; - if (typeof k === 'string' && (k.indexOf('get') === 0 || k.indexOf('query') === 0 || k.indexOf('find') === 0)) return function(){ return k === 'querySelectorAll' || k === 'getElementsByTagName' || k === 'getElementsByClassName' ? [] : null; }; - return function(){ return __mkObj(name + '.' + String(k)); }; - }, - has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; } - }); -} -function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } -function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } -var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' }); -var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' }); -var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' }); -var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); - var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } }); -window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; -var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history; -var __R = null, __E = null; -function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } -var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); -var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); }; -var getComputedStyle = __getComputedStyle; -`; - function extractDuckDuckGoContent(data: unknown): string { if (!data || typeof data !== "object") return ""; const record = data as Record; @@ -247,83 +182,6 @@ function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilit return { reasoningEffort: null }; } -function countHtmlElements(node: unknown): number { - if (!node || typeof node !== "object") return 0; - const record = node as { nodeName?: string; childNodes?: unknown[] }; - const own = record.nodeName && record.nodeName !== "#document-fragment" ? 1 : 0; - let childCount = 0; - for (const child of record.childNodes ?? []) { - childCount += countHtmlElements(child); - } - return own + childCount; -} - -function buildHtmlLookup(js: string): Record { - const lookup: Record = {}; - const seen = new Set(); - const pattern = /(['"])(<[^'"]{1,400}?)\1/g; - for (const match of js.matchAll(pattern)) { - const html = match[2]; - if (seen.has(html)) continue; - seen.add(html); - const fragment = parseFragment(html); - lookup[html] = { - html: serialize(fragment), - count: Math.max(0, countHtmlElements(fragment) - 1), - }; - } - return lookup; -} - -function sha256Base64(value: string): string { - return createHash("sha256").update(value, "utf8").digest("base64"); -} - -async function solveDuckDuckGoChallenge(challenge: string, userAgent: string): Promise { - // SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext. - // The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout - // to limit DoS risk. This is intentional for the DDG challenge solver to work. - const js = Buffer.from(challenge, "base64").toString("utf8"); - const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(userAgent)).replace( - "__DDG_HTML_LOOKUP__", - JSON.stringify(buildHtmlLookup(js)) - ); - const context = vm.createContext({}); - vm.runInContext(stubs, context, { timeout: 5000 }); - const result = (await vm.runInContext(js, context, { - timeout: 5000, - })) as DuckDuckGoChallengeResult; - const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : []; - if (clientHashes.length === 0) - throw new Error("DuckDuckGo challenge returned empty client_hashes"); - clientHashes[0] = userAgent; - result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash))); - return Buffer.from(JSON.stringify(result), "utf8").toString("base64"); -} - -function makeDuckDuckGoFeSignals(): string { - const start = Date.now() - 3000; - let delta = 80 + Math.floor(Math.random() * 101); - const events: Array> = [{ name: "onboarding_impression_1", delta }]; - delta += 120 + Math.floor(Math.random() * 141); - events.push({ name: "onboarding_impression_2", delta }); - delta += 200 + Math.floor(Math.random() * 301); - events.push({ name: "startNewChat", delta }); - const keyEvents = 6 + Math.floor(Math.random() * 13); - for (let i = 0; i < keyEvents; i++) { - delta += 40 + Math.floor(Math.random() * 141); - events.push({ name: "user_input", delta }); - } - delta += 120 + Math.floor(Math.random() * 231); - events.push({ name: "user_submit", delta }); - const payload = { - start, - events, - end: Math.max(delta + 20 + Math.floor(Math.random() * 71), 3000), - }; - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); -} - function extractDuckDuckGoFeVersion(html: string): string | null { return html.match(FE_VERSION_PATTERN)?.[0] ?? null; } diff --git a/open-sse/executors/duckduckgo-web/challenge.ts b/open-sse/executors/duckduckgo-web/challenge.ts new file mode 100644 index 0000000000..4c3f3a6132 --- /dev/null +++ b/open-sse/executors/duckduckgo-web/challenge.ts @@ -0,0 +1,151 @@ +// DuckDuckGo anti-abuse challenge solver + FE signals (pure of module state). +// SECURITY: solveDuckDuckGoChallenge runs upstream-supplied JS in a vm sandbox with a +// 5s timeout (see inline note). Extracted verbatim from duckduckgo-web.ts. +import { createHash } from "node:crypto"; +import vm from "node:vm"; +import { parseFragment, serialize } from "parse5"; + +export const CHALLENGE_STUBS = String.raw` +var __ua = __DDG_REAL_UA__; +var __HTML_LOOKUP = __DDG_HTML_LOOKUP__; +export function __makeHtmlElement(tag) { + var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' }; + var el = { + tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1, + children: [], childNodes: [], classList: [], dataset: {}, + offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1, + getBoundingClientRect: function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }, + setAttribute: function(){}, removeAttribute: function(){}, + getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; }, + hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; }, + addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; }, + querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; }, + cloneNode: function(){ return __makeHtmlElement(tag); } + }; + Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true }); + Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true }); + Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + ''; }, enumerable: true }); + Object.defineProperty(el, 'srcdoc', { get: function(){ return state._srcdoc||''; }, set: function(v){ state._srcdoc = String(v); }, enumerable: true }); + Object.defineProperty(el, 'contentWindow', { get: function(){ var w = {}; w.document = __ifDoc; w.Proxy = Proxy; w.self = w; w.top = w; w.parent = w; w.window = w; return w; }, enumerable: true }); + Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true }); + return el; +} +export function __mkObj(name, base) { + base = base || {}; + return new Proxy(base, { + get: function(t, k) { + if (k in t) return t[k]; + if (k === Symbol.toPrimitive) return function(){ return ''; }; + if (k === Symbol.iterator) return undefined; + if (k === 'then' || k === 'catch' || k === 'finally') return undefined; + if (k === 'constructor') return Object; + if (k === 'toString' || k === 'valueOf') return function(){ return '[object ' + name + ']'; }; + if (k === 'length') return 0; + if (k === 'nodeType') return 1; + if (k === 'tagName' || k === 'nodeName') return 'DIV'; + if (k === 'innerHTML' || k === 'outerHTML' || k === 'textContent' || k === 'innerText' || k === 'value') return ''; + if (k === 'children' || k === 'childNodes' || k === 'classList') return []; + // Real numeric layout values for the DDG challenge DOM probes. + if (k === 'offsetWidth' || k === 'offsetHeight' || k === 'clientWidth' || k === 'clientHeight' || k === 'scrollHeight' || k === 'scrollWidth') return 1; + if (k === 'getBoundingClientRect') return function(){ return { x: 0, y: 0, top: 0, left: 0, right: 1, bottom: 1, width: 1, height: 1, toJSON: function(){ return {}; } }; }; + if (typeof k === 'string' && (k.indexOf('get') === 0 || k.indexOf('query') === 0 || k.indexOf('find') === 0)) return function(){ return k === 'querySelectorAll' || k === 'getElementsByTagName' || k === 'getElementsByClassName' ? [] : null; }; + return function(){ return __mkObj(name + '.' + String(k)); }; + }, + has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; } + }); +} +export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } +export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } +var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' }); +var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' }); +var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' }); +var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); + var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } }); +window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; +var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history; +var __R = null, __E = null; +export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } +var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); +var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); }; +var getComputedStyle = __getComputedStyle; +`; + +export function countHtmlElements(node: unknown): number { + if (!node || typeof node !== "object") return 0; + const record = node as { nodeName?: string; childNodes?: unknown[] }; + const own = record.nodeName && record.nodeName !== "#document-fragment" ? 1 : 0; + let childCount = 0; + for (const child of record.childNodes ?? []) { + childCount += countHtmlElements(child); + } + return own + childCount; +} + +export function buildHtmlLookup(js: string): Record { + const lookup: Record = {}; + const seen = new Set(); + const pattern = /(['"])(<[^'"]{1,400}?)\1/g; + for (const match of js.matchAll(pattern)) { + const html = match[2]; + if (seen.has(html)) continue; + seen.add(html); + const fragment = parseFragment(html); + lookup[html] = { + html: serialize(fragment), + count: Math.max(0, countHtmlElements(fragment) - 1), + }; + } + return lookup; +} + +export function sha256Base64(value: string): string { + return createHash("sha256").update(value, "utf8").digest("base64"); +} + +export async function solveDuckDuckGoChallenge( + challenge: string, + userAgent: string +): Promise { + // SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext. + // The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout + // to limit DoS risk. This is intentional for the DDG challenge solver to work. + const js = Buffer.from(challenge, "base64").toString("utf8"); + const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(userAgent)).replace( + "__DDG_HTML_LOOKUP__", + JSON.stringify(buildHtmlLookup(js)) + ); + const context = vm.createContext({}); + vm.runInContext(stubs, context, { timeout: 5000 }); + const result = (await vm.runInContext(js, context, { + timeout: 5000, + })) as DuckDuckGoChallengeResult; + const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : []; + if (clientHashes.length === 0) + throw new Error("DuckDuckGo challenge returned empty client_hashes"); + clientHashes[0] = userAgent; + result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash))); + return Buffer.from(JSON.stringify(result), "utf8").toString("base64"); +} + +export function makeDuckDuckGoFeSignals(): string { + const start = Date.now() - 3000; + let delta = 80 + Math.floor(Math.random() * 101); + const events: Array> = [{ name: "onboarding_impression_1", delta }]; + delta += 120 + Math.floor(Math.random() * 141); + events.push({ name: "onboarding_impression_2", delta }); + delta += 200 + Math.floor(Math.random() * 301); + events.push({ name: "startNewChat", delta }); + const keyEvents = 6 + Math.floor(Math.random() * 13); + for (let i = 0; i < keyEvents; i++) { + delta += 40 + Math.floor(Math.random() * 141); + events.push({ name: "user_input", delta }); + } + delta += 120 + Math.floor(Math.random() * 231); + events.push({ name: "user_submit", delta }); + const payload = { + start, + events, + end: Math.max(delta + 20 + Math.floor(Math.random() * 71), 3000), + }; + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} diff --git a/open-sse/executors/firecrawl-fetch.ts b/open-sse/executors/firecrawl-fetch.ts index c8ae752466..b2c59386ec 100644 --- a/open-sse/executors/firecrawl-fetch.ts +++ b/open-sse/executors/firecrawl-fetch.ts @@ -6,13 +6,37 @@ * * Free tier: 500 fetches/month, no credit card required. * Docs: https://docs.firecrawl.dev/api-reference/endpoint/scrape + * + * Self-hosted: set FIRECRAWL_BASE_URL to point at a self-hosted Firecrawl + * instance (e.g. http://127.0.0.1:3002). The API key is only required against + * the default cloud base URL — self-hosted instances typically run with no + * auth in front of them, so credentials.apiKey becomes optional in that case. */ import { sanitizeErrorMessage, buildErrorBody } from "../utils/error.ts"; import type { WebFetchResult, WebFetchFormat, WebFetchCredentials } from "../handlers/webFetch.ts"; -const FIRECRAWL_API_BASE = "https://api.firecrawl.dev/v1"; -const FIRECRAWL_TIMEOUT_MS = 30_000; +const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev"; +const FIRECRAWL_DEFAULT_TIMEOUT_MS = 30_000; + +/** Resolve the configured Firecrawl base URL, falling back to the public cloud API. */ +function getFirecrawlBaseUrl(): string { + const envBase = process.env.FIRECRAWL_BASE_URL?.trim(); + return envBase ? envBase.replace(/\/+$/, "") : FIRECRAWL_DEFAULT_BASE_URL; +} + +/** Whether the given base URL is the default Firecrawl cloud endpoint. */ +function isDefaultFirecrawlBaseUrl(baseUrl: string): boolean { + return baseUrl === FIRECRAWL_DEFAULT_BASE_URL; +} + +/** Resolve the configured request timeout, falling back to a sane default. */ +function getFirecrawlTimeoutMs(): number { + const raw = process.env.FIRECRAWL_TIMEOUT_MS; + if (!raw) return FIRECRAWL_DEFAULT_TIMEOUT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : FIRECRAWL_DEFAULT_TIMEOUT_MS; +} function mapFormat(format: WebFetchFormat): string { switch (format) { @@ -43,7 +67,12 @@ interface FirecrawlScrapeOptions { export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise { const { url, format, depth, waitForSelector, includeMetadata, credentials } = opts; - if (!credentials.apiKey) { + const baseUrl = getFirecrawlBaseUrl(); + const isDefaultBaseUrl = isDefaultFirecrawlBaseUrl(baseUrl); + + // The API key is mandatory for the public Firecrawl cloud API, but optional + // once a custom (self-hosted) base URL is configured. + if (isDefaultBaseUrl && !credentials.apiKey) { const body = buildErrorBody(401, "Firecrawl API key required"); return { success: false, status: 401, error: body.error.message }; } @@ -71,15 +100,17 @@ export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise controller.abort(), FIRECRAWL_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), getFirecrawlTimeoutMs()); try { - const response = await fetch(`${FIRECRAWL_API_BASE}/scrape`, { + const headers: Record = { "Content-Type": "application/json" }; + if (credentials.apiKey) { + headers.Authorization = `Bearer ${credentials.apiKey}`; + } + + const response = await fetch(`${baseUrl}/v1/scrape`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${credentials.apiKey}`, - }, + headers, body: JSON.stringify(requestBody), signal: controller.signal, }); diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index 9ccec9a46b..888e10b94a 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -10,6 +10,13 @@ import { } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { + buildStreamingResponse, + buildJsonCompletion, + buildToolJsonCompletion, + buildToolStreamingResponse, +} from "./gitlabResponses.ts"; import { buildGitLabDirectGatewayUrl, buildGitLabOAuthEndpoints, @@ -112,101 +119,6 @@ function toOpenAIError(status: number, message: string): Response { ); } -function buildSseChunk(data: unknown): string { - return `data: ${JSON.stringify(data)}\n\n`; -} - -function buildStreamingResponse( - content: string, - model: string, - id: string, - created: number -): Response { - const encoder = new TextEncoder(); - - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }) - ) - ); - - if (content) { - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { content }, finish_reason: null }], - }) - ) - ); - } - - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }) - ) - ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }, - }); - - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); -} - -function buildJsonCompletion( - content: string, - model: string, - id: string, - created: number -): Response { - const estimated = Math.max(1, Math.ceil(content.length / 4)); - return new Response( - JSON.stringify({ - id, - object: "chat.completion", - created, - model, - choices: [ - { - index: 0, - message: { role: "assistant", content }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: estimated, - completion_tokens: estimated, - total_tokens: estimated * 2, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - function mergeCredentials( current: ProviderCredentials, patch: Partial | null | undefined @@ -572,9 +484,20 @@ export class GitlabExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - const prompt = buildPrompt( - (input.body as Record)?.messages as OpenAIMessage[] + const bodyObj = (input.body as Record) || {}; + const rawMessages = (bodyObj.messages as OpenAIMessage[]) || []; + + // Emulate OpenAI tool calling for GitLab Duo (which has no native function + // calling). When `tools` are present we serialize the tool contract into the + // prompt and parse `{...}` blocks back out of the completion text + // into OpenAI `tool_calls` — the same web-tool-emulation idiom used by the + // qwen-web / duckduckgo-web executors (#6051). + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + bodyObj, + rawMessages as Array<{ role: string; content: unknown }> ); + + const prompt = buildPrompt(effectiveMessages as OpenAIMessage[]); if (!prompt) { return { response: toOpenAIError(400, "GitLab Duo requires at least one user message"), @@ -598,7 +521,7 @@ export class GitlabExecutor extends BaseExecutor { const transformedBody = this.transformRequest( input.model, - (input.body as Record) || {}, + { ...bodyObj, messages: effectiveMessages }, false, activeCredentials ); @@ -684,6 +607,24 @@ export class GitlabExecutor extends BaseExecutor { const resolvedModel = resolveResponseModel(payload, input.model); const responseId = `chatcmpl-gitlab-${randomUUID()}`; const created = Math.floor(Date.now() / 1000); + + if (hasTools) { + const { + content: toolContent, + toolCalls, + finishReason, + } = buildToolAwareResult(content, requestedTools, "gitlab"); + const message: Record = { role: "assistant", content: toolContent }; + if (toolCalls) { + message.tool_calls = toolCalls; + message.content = null; + } + const response = input.stream + ? buildToolStreamingResponse(message, finishReason, resolvedModel, responseId, created) + : buildToolJsonCompletion(message, finishReason, resolvedModel, responseId, created); + return { response, url: activeTarget.url, headers: requestHeaders, transformedBody }; + } + const response = input.stream ? buildStreamingResponse(content, resolvedModel, responseId, created) : buildJsonCompletion(content, resolvedModel, responseId, created); diff --git a/open-sse/executors/gitlabResponses.ts b/open-sse/executors/gitlabResponses.ts new file mode 100644 index 0000000000..0e98bb216c --- /dev/null +++ b/open-sse/executors/gitlabResponses.ts @@ -0,0 +1,191 @@ +// OpenAI-shaped response builders for the GitLab Duo executor. Extracted from +// gitlab.ts (leaf module — must not import from gitlab.ts) so the executor stays +// under the file-size cap. Covers plain text (streaming + JSON) and the +// tool_calls emulation variants added for #6051. + +function buildSseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export function buildStreamingResponse( + content: string, + model: string, + id: string, + created: number +): Response { + const encoder = new TextEncoder(); + + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }) + ) + ); + + if (content) { + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content }, finish_reason: null }], + }) + ) + ); + } + + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +export function buildJsonCompletion( + content: string, + model: string, + id: string, + created: number +): Response { + const estimated = Math.max(1, Math.ceil(content.length / 4)); + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message: { role: "assistant", content }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: estimated, + completion_tokens: estimated, + total_tokens: estimated * 2, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +export function buildToolJsonCompletion( + message: Record, + finishReason: string, + model: string, + id: string, + created: number +): Response { + const contentForEstimate = typeof message.content === "string" ? message.content : ""; + const estimated = Math.max(1, Math.ceil(contentForEstimate.length / 4)); + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model, + choices: [ + { + index: 0, + message, + finish_reason: finishReason, + }, + ], + usage: { + prompt_tokens: estimated, + completion_tokens: estimated, + total_tokens: estimated * 2, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +export function buildToolStreamingResponse( + message: Record, + finishReason: string, + model: string, + id: string, + created: number +): Response { + const encoder = new TextEncoder(); + + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }) + ) + ); + + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: message, finish_reason: null }], + }) + ) + ); + + controller.enqueue( + encoder.encode( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index 880d95420c..df311699d3 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -27,6 +27,22 @@ import { type TlsFetchResult, } from "../services/grokTlsClient.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import type { GrokStreamEvent } from "./grok-web/types.ts"; +import { + type OpenAIToolCall, + type GrokToolRegistry, + buildGrokToolRegistry, + buildGrokMessage, + parseClientToolCallMarkup, + hasOpenToolCallMarkup, +} from "./grok-web/tool-bridge.ts"; +import { mapGrokNativeToolToOpenAI } from "./grok-web/native-tools.ts"; +import { + GrokMarkupFilter, + cleanGrokContentText, + cleanGrokThinkingText, + extractStructuredReasoning, +} from "./grok-web/text-cleanup.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -89,875 +105,6 @@ function randomHex(bytes: number): string { return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); } -// ─── OpenAI message → Grok query translation ─────────────────────────────── - -interface OpenAIToolCall { - id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; -} - -interface GrokToolRegistry { - enabled: boolean; - toolsByName: Map; - lastUserText: string; - executedToolKeys: Set; - completedToolCalls: string[]; -} - -interface GrokFunctionToolSummary { - name: string; - description?: string; - parameters: unknown; -} - -type NativeToolIntent = "bash" | "readFile" | "webSearch" | "browsePage"; - -interface ToolBridgeContext { - lastUserText: string; -} - -function stripInjectedRuntimeReminders(text: string): string { - return text - .replace(/\n?---\s*\n\s*[\s\S]*?<\/internal_reminder>/gi, "") - .replace(/[\s\S]*?<\/internal_reminder>/gi, "") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -function extractTextContent(msg: Record): string { - if (typeof msg.content === "string") return stripInjectedRuntimeReminders(msg.content); - if (Array.isArray(msg.content)) { - return stripInjectedRuntimeReminders( - (msg.content as Array>) - .filter((c) => c.type === "text") - .map((c) => String(c.text || "")) - .join(" ") - ); - } - return ""; -} - -function getLastUserText(messages: Array>): string { - for (let i = messages.length - 1; i >= 0; i--) { - if (String(messages[i].role || "") === "user") return extractTextContent(messages[i]); - } - return ""; -} - -function normalizeToolArgumentObject(value: unknown): Record { - if (!value) return {}; - if (typeof value === "object") return value as Record; - if (typeof value === "string") { - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : { input: value }; - } catch { - return { input: value }; - } - } - return {}; -} - -function stableJson(value: unknown): string { - if (!value || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) - .join(",")}}`; -} - -function toolCallKey(name: string, args: unknown): string { - return `${name}:${stableJson(normalizeToolArgumentObject(args))}`; -} - -function normalizeShellCommand(command: string): string { - return command.trim().replace(/\s+/g, " "); -} - -function normalizePathValue(path: string): string { - return path.trim().replace(/^['"]|['"]$/g, ""); -} - -function normalizeQueryValue(query: string): string { - return query.trim().replace(/\s+/g, " ").toLowerCase(); -} - -function semanticToolKey(name: string, args: unknown): string { - const normalizedName = name.trim(); - const record = normalizeToolArgumentObject(args); - const command = firstString(record.command, record.cmd, record.shell); - if (command) return `${normalizedName}:command:${normalizeShellCommand(command)}`; - - const url = firstString(record.url, record.uri); - if (url) return `${normalizedName}:url:${normalizePathValue(url)}`; - - const path = firstString(record.filePath, record.file_path, record.path, record.filename); - if (path) return `${normalizedName}:path:${normalizePathValue(path)}`; - - const query = firstString(record.query, record.search); - if (query) return `${normalizedName}:query:${normalizeQueryValue(query)}`; - - return toolCallKey(normalizedName, record); -} - -function summarizeCompletedToolCall(name: string, args: unknown): string { - const record = normalizeToolArgumentObject(args); - const command = firstString(record.command, record.cmd, record.shell); - if (command) return `${name}(command=${JSON.stringify(normalizeShellCommand(command))})`; - const url = firstString(record.url, record.uri); - if (url) return `${name}(url=${JSON.stringify(normalizePathValue(url))})`; - const path = firstString(record.filePath, record.file_path, record.path, record.filename); - if (path) return `${name}(path=${JSON.stringify(normalizePathValue(path))})`; - const query = firstString(record.query, record.search); - if (query) return `${name}(query=${JSON.stringify(normalizeQueryValue(query))})`; - return `${name}(${stableJson(record)})`; -} - -function getExecutedToolState(messages: Array>): { - keys: Set; - summaries: string[]; -} { - let lastUserIdx = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (String(messages[i].role || "") === "user") { - lastUserIdx = i; - break; - } - } - const callsById = new Map }>(); - const executed = new Set(); - const summaries: string[] = []; - for (let i = Math.max(0, lastUserIdx + 1); i < messages.length; i++) { - const msg = messages[i]; - if (String(msg.role || "") === "assistant" && Array.isArray(msg.tool_calls)) { - for (const call of msg.tool_calls as Array>) { - const id = typeof call.id === "string" ? call.id : ""; - const fn = call.function; - if (!id || !fn || typeof fn !== "object") continue; - const fnRecord = fn as Record; - const name = typeof fnRecord.name === "string" ? fnRecord.name : ""; - if (!name) continue; - callsById.set(id, { name, args: normalizeToolArgumentObject(fnRecord.arguments) }); - } - } - if (String(msg.role || "") === "tool") { - const id = typeof msg.tool_call_id === "string" ? msg.tool_call_id : ""; - const call = id ? callsById.get(id) : null; - if (call) { - const key = semanticToolKey(call.name, call.args); - executed.add(key); - const summary = summarizeCompletedToolCall(call.name, call.args); - if (!summaries.includes(summary)) summaries.push(summary); - } - } - } - return { keys: executed, summaries }; -} - -function extractFirstUrl(text: string): string | undefined { - const match = text.match(/https?:\/\/[^\s)\]}>"']+/i); - if (match?.[0]) return match[0].replace(/[.,;:!?]+$/, ""); - const domain = text.match(/(?:^|\s)((?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s)\]}>"']*)?)/i)?.[1]; - return domain ? `https://${domain.replace(/[.,;:!?]+$/, "")}` : undefined; -} - -function wantsUrlFetch(text: string): boolean { - return ( - /\b(webfetch|web_fetch|fetch|browse|open|read|lee|abre|extrae|investiga|analiza|resume|summarize|de qu[eé] va)\b/i.test( - text - ) && !!extractFirstUrl(text) - ); -} - -function forcedToolChoiceName(toolChoice: unknown): string | null { - if (!toolChoice || typeof toolChoice !== "object") return null; - const record = toolChoice as Record; - if (record.type !== "function" || !record.function || typeof record.function !== "object") - return null; - const name = (record.function as Record).name; - return typeof name === "string" && name.trim() ? name.trim() : null; -} - -function parseOpenAIMessages( - messages: Array>, - beforeLatestUser = "" -): string { - const parts: string[] = []; - let lastUserIdx = -1; - let lastUserSourceIdx = -1; - - for (let i = messages.length - 1; i >= 0; i--) { - if (String(messages[i].role || "") === "user") { - lastUserSourceIdx = i; - break; - } - } - - // Extract text from each message - const extracted: Array<{ role: string; text: string }> = []; - - for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { - const msg = messages[msgIdx]; - let role = String(msg.role || "user"); - if (role === "developer") role = "system"; - - let content = extractTextContent(msg); - if (role === "tool") { - if (msgIdx < lastUserSourceIdx) continue; - const toolName = typeof msg.name === "string" ? msg.name : "unknown_tool"; - const toolCallId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "unknown_call"; - content = `CLIENT TOOL RESULT from caller runtime for ${toolName} (${toolCallId}). Use this result to answer; do not call the same tool again:\n${content}`; - } else if (role === "assistant" && Array.isArray(msg.tool_calls)) { - if (msgIdx < lastUserSourceIdx) continue; - const calls = (msg.tool_calls as Array>).map((call) => ({ - id: call.id, - function: call.function, - })); - content = [content, `Previous assistant tool calls: ${JSON.stringify(calls)}`] - .filter(Boolean) - .join("\n"); - } - if (!content.trim()) continue; - extracted.push({ role, text: content }); - } - - // Find last user message index - for (let i = extracted.length - 1; i >= 0; i--) { - if (extracted[i].role === "user") { - lastUserIdx = i; - break; - } - } - - // Build combined message — last user message is raw, others are prefixed - for (let i = 0; i < extracted.length; i++) { - const { role, text } = extracted[i]; - if (i === lastUserIdx) { - parts.push(text); - } else { - parts.push(`${role}: ${text}`); - } - } - - if (beforeLatestUser.trim()) { - parts.push(beforeLatestUser.trim()); - } - - return parts.join("\n\n"); -} - -function buildGrokToolRegistry(body: Record): GrokToolRegistry { - const tools = Array.isArray(body.tools) ? (body.tools as Array>) : []; - const messages = Array.isArray(body.messages) - ? (body.messages as Array>) - : []; - const lastUserText = getLastUserText(messages); - const executedToolState = getExecutedToolState(messages); - const toolChoice = body.tool_choice ?? "auto"; - - if (toolChoice === "none") { - return { - enabled: false, - toolsByName: new Map(), - lastUserText, - executedToolKeys: executedToolState.keys, - completedToolCalls: executedToolState.summaries, - }; - } - - const functionTools: GrokFunctionToolSummary[] = tools - .map((tool) => { - const fn = tool?.function; - if (tool?.type !== "function" || !fn || typeof fn !== "object") return null; - const record = fn as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - if (!name) return null; - return { - name, - ...(typeof record.description === "string" ? { description: record.description } : {}), - parameters: record.parameters || { type: "object", properties: {} }, - }; - }) - .filter((tool): tool is GrokFunctionToolSummary => Boolean(tool)); - const forcedName = forcedToolChoiceName(toolChoice); - const visibleTools = forcedName - ? functionTools.filter((tool) => tool.name === forcedName) - : functionTools; - - return { - enabled: visibleTools.length > 0, - toolsByName: new Map(visibleTools.map((tool) => [tool.name, tool])), - lastUserText, - executedToolKeys: executedToolState.keys, - completedToolCalls: executedToolState.summaries, - }; -} - -function getSchemaProperties(parameters: unknown): Record { - if (!parameters || typeof parameters !== "object") return {}; - const properties = (parameters as Record).properties; - return properties && typeof properties === "object" - ? (properties as Record) - : {}; -} - -function getSchemaRequired(parameters: unknown): string[] { - if (!parameters || typeof parameters !== "object") return []; - const required = (parameters as Record).required; - return Array.isArray(required) - ? required.filter((key): key is string => typeof key === "string") - : []; -} - -function formatToolArgsSummary(parameters: unknown): string { - const properties = getSchemaProperties(parameters); - const propNames = Object.keys(properties); - const required = getSchemaRequired(parameters); - const segments: string[] = []; - if (propNames.length > 0) segments.push(`args=${propNames.join(",")}`); - if (required.length > 0) segments.push(`required=${required.join(",")}`); - return segments.length > 0 ? ` (${segments.join("; ")})` : ""; -} - -function toolText(tool: GrokFunctionToolSummary): string { - return `${tool.name} ${tool.description || ""}`.toLowerCase(); -} - -function hasAnyProperty(tool: GrokFunctionToolSummary, names: string[]): boolean { - const properties = getSchemaProperties(tool.parameters); - const lowerProps = new Set(Object.keys(properties).map((key) => key.toLowerCase())); - return names.some((name) => lowerProps.has(name.toLowerCase())); -} - -function isTerminalTool(tool: GrokFunctionToolSummary): boolean { - if (isMetaOrInfrastructureTool(tool)) return false; - const text = toolText(tool); - const name = tool.name.toLowerCase(); - const explicitName = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/.test( - name - ); - const explicitText = - /\b(?:run|execute).{0,24}\b(?:shell|bash|terminal|command)\b|\b(?:shell|bash|terminal)\b/.test( - text - ); - return explicitName || (hasAnyProperty(tool, ["command", "cmd", "shell"]) && explicitText); -} - -function isFileReadTool(tool: GrokFunctionToolSummary): boolean { - const text = toolText(tool); - return ( - hasAnyProperty(tool, ["filePath", "file_path", "path"]) && - /\b(read|file|filesystem|open)\b/.test(text) && - !/\b(write|edit|patch|delete|remove|grep|search|bash|shell|command)\b/.test(text) - ); -} - -function isUrlFetchTool(tool: GrokFunctionToolSummary): boolean { - const text = toolText(tool); - const name = tool.name.toLowerCase(); - const explicitName = - /\b(webfetch|web.fetch|fetch_url|url_fetch|read_url|browse_page|browsepage)\b/.test(name); - const explicitUrlText = - /\b(?:fetch|browse|read).{0,32}\b(?:url|uri|web page|page content)\b|\b(?:url|uri|web page|page content).{0,32}\b(?:fetch|browse|read)\b/.test( - text - ); - return ( - explicitName || - (!isMetaOrInfrastructureTool(tool) && hasAnyProperty(tool, ["url", "uri"]) && explicitUrlText) - ); -} - -function isWebSearchTool(tool: GrokFunctionToolSummary): boolean { - const text = toolText(tool); - return ( - hasAnyProperty(tool, ["query", "search"]) && - /\b(web|internet|exa|browser|browse|serp)\b/.test(text) && - !isMetaOrInfrastructureTool(tool) && - !isContextMemoryTool(tool) - ); -} - -function isContextMemoryTool(tool: GrokFunctionToolSummary): boolean { - const text = toolText(tool); - return /\b(ctx_|memory|memories|conversation history|session notes|git commits|project memories|context.db|magic context)\b/.test( - text - ); -} - -function isMetaOrInfrastructureTool(tool: GrokFunctionToolSummary): boolean { - const text = toolText(tool); - return /\b(mcp|mcpproxy|upstream|registry|registries|quarantine|oauth|cache key|token usage|session notes|conversation transcript|handoff|context management|memory|memories|lsp|language server|plan file|server management|tool discovery|tools? using bm25)\b/.test( - text - ); -} - -function baseToolOrderScore(tool: GrokFunctionToolSummary): number { - if (isUrlFetchTool(tool)) return 90; - if (isWebSearchTool(tool)) return 85; - if (isFileReadTool(tool)) return 75; - if (isTerminalTool(tool)) return 70; - if (/\b(glob|grep|search files?|file search|content search)\b/.test(toolText(tool))) return 60; - if (/\b(edit|write|patch|modify|apply)\b/.test(toolText(tool))) return 50; - if (/\b(task|agent|delegate|subagent)\b/.test(toolText(tool))) return 40; - if (isMetaOrInfrastructureTool(tool)) return 10; - if (isContextMemoryTool(tool)) return 20; - return 30; -} - -function latestUserIntentScore(tool: GrokFunctionToolSummary, lastUserText: string): number { - const user = lastUserText.toLowerCase(); - const hasPath = /(?:^|\s|["'`])(?:~|\.?\.?\/|\/)[^\s"'`]+/.test(lastUserText); - const hasUrl = !!extractFirstUrl(lastUserText); - const asksLineCount = /\b(l[ií]neas?|line count|cu[aá]ntas? l[ií]neas?|wc\s+-l)\b/.test(user); - const asksFileContent = - /\b(lee|leer|read|archivo|file|json|config|modelo|default|por defecto|de qu[eé] va|consiste|contenido)\b/.test( - user - ) && hasPath; - const asksContext = - /\b(contexto|memoria|historial|conversation history|project memories|ctx_|memory|memories|recordabas?)\b/.test( - user - ); - const asksWeb = - !asksContext && - /\b(web|internet|fuente|oficial|release|versi[oó]n|ubuntu|latest|actual|contrasta|busca|search)\b/.test( - user - ); - let score = 0; - - if (asksFileContent && isFileReadTool(tool)) score += 160; - if (asksFileContent && isTerminalTool(tool)) score += asksLineCount ? 70 : 20; - if (asksLineCount && isTerminalTool(tool)) score += 120; - if (asksLineCount && isFileReadTool(tool)) score += asksFileContent ? 90 : 30; - if (asksWeb && !hasUrl && isWebSearchTool(tool)) score += 170; - if (asksWeb && !hasUrl && isUrlFetchTool(tool)) score += 35; - if (asksWeb && isContextMemoryTool(tool)) score -= 120; - if (asksContext && isContextMemoryTool(tool)) score += 170; - if (asksContext && isWebSearchTool(tool)) score -= 80; - - if (isContextMemoryTool(tool) && (asksFileContent || asksWeb)) score -= 80; - return score; -} - -function orderedToolsForManifest( - toolRegistry: GrokToolRegistry -): Array<{ tool: GrokFunctionToolSummary; score: number }> { - return [...toolRegistry.toolsByName.values()] - .map((tool, index) => ({ - tool, - score: latestUserIntentScore(tool, toolRegistry.lastUserText) + baseToolOrderScore(tool), - meta: isMetaOrInfrastructureTool(tool), - index, - })) - .sort((a, b) => b.score - a.score || Number(a.meta) - Number(b.meta) || a.index - b.index) - .map(({ tool, score }) => ({ tool, score })); -} - -function formatToolManifestEntry(tool: GrokFunctionToolSummary, rank: number): string { - const desc = tool.description ? `\n description: ${tool.description}` : ""; - const args = formatToolArgsSummary(tool.parameters).trim(); - return `${rank}. name: ${tool.name}${args ? `\n ${args.slice(1, -1)}` : ""}${desc ? desc.replace(/\n /g, "\n ") : ""}`; -} - -function buildClientToolManifest(toolRegistry: GrokToolRegistry, toolChoice: unknown): string { - if (!toolRegistry.enabled) return ""; - const orderedTools = orderedToolsForManifest(toolRegistry); - const lines = [ - 'CLIENT_TOOLS: use this caller-runtime tool list as the tool interface for this request. To call one, respond only with {"name":"exact_tool_name","arguments":{...}}. After tool results, answer normally.', - `tool_choice=${JSON.stringify(toolChoice ?? "auto")}`, - ...(toolRegistry.completedToolCalls.length > 0 - ? [ - "completed_tool_calls:", - ...toolRegistry.completedToolCalls.map((summary) => `- ${summary}`), - "Do not repeat completed tool calls unless a different result is required; use their tool results to answer.", - ] - : []), - "tools (priority order for this request):", - ...orderedTools.map(({ tool }, index) => formatToolManifestEntry(tool, index + 1)), - ]; - return lines.join("\n"); -} - -function buildGrokMessage( - messages: Array>, - toolRegistry: GrokToolRegistry, - toolChoice: unknown -): string { - const manifest = buildClientToolManifest(toolRegistry, toolChoice); - return parseOpenAIMessages(messages, manifest); -} - -function propertyType(properties: Record, key: string): string | undefined { - const prop = properties[key]; - if (!prop || typeof prop !== "object") return undefined; - const type = (prop as Record).type; - return typeof type === "string" ? type : undefined; -} - -function hasValue(value: unknown): boolean { - return value !== undefined && value !== null && value !== ""; -} - -function firstString(...values: unknown[]): string | undefined { - for (const value of values) { - if (typeof value === "string" && value.trim()) return value; - } - return undefined; -} - -function defaultRequiredValue( - key: string, - type: string | undefined, - args: Record, - intent: string -): unknown { - const lower = key.toLowerCase(); - const command = firstString(args.command, args.cmd, args.shell, args.input); - const path = firstString(args.filePath, args.file_path, args.path, args.filename); - const query = firstString(args.query, args.search, args.input); - const url = firstString(args.url, args.uri); - - if (lower === "command" || lower === "cmd") return command; - if (lower === "filepath" || lower === "file_path" || lower === "path") return path; - if (lower === "query" || lower === "search") return query; - if (lower === "url" || lower === "uri") return url; - if (lower === "input") return query || url || command || path; - if (lower === "description" || lower === "reason" || lower === "intent_reason") { - if (command) return `Execute shell command: ${command}`; - if (path) return `Read file: ${path}`; - if (url) return `Fetch URL: ${url}`; - if (query) return `Search: ${query}`; - return `Grok Web ${intent} tool call`; - } - if (lower === "intent_data_sensitivity") return "private"; - return undefined; -} - -function extractNumericUserParam(text: string, names: string[]): number | undefined { - const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); - const re = new RegExp(`\\b(?:${escaped})\\s*(?:=|:|a|de)?\\s*(\\d+)`, "i"); - const match = text.match(re); - if (!match) return undefined; - const value = Number(match[1]); - return Number.isFinite(value) ? value : undefined; -} - -function adaptArgumentsToDeclaredTool( - toolName: string, - args: Record, - toolRegistry: GrokToolRegistry, - intent: string, - options: { preserveUnknownArgs?: boolean } = { preserveUnknownArgs: true } -): Record { - const tool = toolRegistry.toolsByName.get(toolName); - if (!tool) return args; - const properties = getSchemaProperties(tool.parameters); - const required = getSchemaRequired(tool.parameters); - const out: Record = { ...args }; - - // Normalize common aliases only when the declared schema expects them. - if ("filePath" in properties && !hasValue(out.filePath)) - out.filePath = firstString(args.filePath, args.file_path, args.path); - if ("file_path" in properties && !hasValue(out.file_path)) - out.file_path = firstString(args.file_path, args.filePath, args.path); - if ("path" in properties && !hasValue(out.path)) - out.path = firstString(args.path, args.filePath, args.file_path); - if ("query" in properties && !hasValue(out.query)) - out.query = firstString(args.query, args.search, args.input); - if ("url" in properties && !hasValue(out.url)) out.url = firstString(args.url, args.uri); - if ("uri" in properties && !hasValue(out.uri)) out.uri = firstString(args.uri, args.url); - if ("input" in properties && !hasValue(out.input)) - out.input = firstString(args.input, args.query, args.command); - - for (const key of required) { - if (hasValue(out[key])) continue; - const value = defaultRequiredValue(key, propertyType(properties, key), out, intent); - if (value !== undefined) out[key] = value; - } - - if (options.preserveUnknownArgs !== false || Object.keys(properties).length === 0) return out; - - const filtered: Record = {}; - for (const key of Object.keys(properties)) { - if (hasValue(out[key])) filtered[key] = out[key]; - } - for (const key of required) { - if (hasValue(out[key])) filtered[key] = out[key]; - } - return filtered; -} - -function normalizeArbitraryToolArguments(value: unknown): Record { - if (!value) return {}; - if (typeof value === "object") return value as Record; - if (typeof value === "string") { - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" ? (parsed as Record) : {}; - } catch { - return { input: value }; - } - } - return {}; -} - -function parseClientToolCallMarkup( - text: string, - toolRegistry: GrokToolRegistry -): OpenAIToolCall[] | null { - if (!toolRegistry.enabled || !text.includes("")) return null; - const calls: OpenAIToolCall[] = []; - const re = /\s*([\s\S]*?)\s*<\/tool_call>/g; - for (const match of text.matchAll(re)) { - let parsed: unknown; - try { - parsed = JSON.parse(match[1]); - } catch { - continue; - } - if (!parsed || typeof parsed !== "object") continue; - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - if (!name || !toolRegistry.toolsByName.has(name)) continue; - const rawArgs = normalizeArbitraryToolArguments(record.arguments); - const args = adaptArgumentsToDeclaredTool(name, rawArgs, toolRegistry, "clientTool", { - preserveUnknownArgs: true, - }); - if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) continue; - calls.push({ - id: - typeof record.id === "string" && record.id.trim() - ? record.id.trim() - : `call_${crypto.randomUUID()}`, - type: "function", - function: { name, arguments: JSON.stringify(args) }, - }); - } - return calls.length > 0 ? calls : null; -} - -function hasOpenToolCallMarkup(text: string): boolean { - return ( - /]*$/.test(text) || - (text.includes("") && !text.includes("")) - ); -} - -function toolScore( - tool: GrokFunctionToolSummary, - intent: NativeToolIntent, - context: ToolBridgeContext -): number { - const name = tool.name.toLowerCase(); - const description = (tool.description || "").toLowerCase(); - const properties = getSchemaProperties(tool.parameters); - const propNames = new Set(Object.keys(properties).map((key) => key.toLowerCase())); - const text = `${name} ${description}`; - const userText = context.lastUserText.toLowerCase(); - let score = 0; - - if (intent === "bash") { - if (!isTerminalTool(tool)) score -= 80; - if (name === "bash") score += 100; - if (["shell", "terminal", "run_command", "execute_command", "exec", "command"].includes(name)) - score += 80; - if (propNames.has("command") || propNames.has("cmd")) score += 60; - if (/bash|shell|terminal|command|execute|run/.test(text)) score += 25; - if (/read|search|grep|web|http|browser|context|note|memory/.test(name)) score -= 50; - } else if (intent === "readFile") { - if (!isFileReadTool(tool)) score -= 60; - if (["read", "read_file", "readfile", "file_read"].includes(name)) score += 100; - if (propNames.has("filepath") || propNames.has("file_path") || propNames.has("path")) - score += 50; - if (/read.*file|file.*read|filesystem/.test(text)) score += 25; - if (/write|edit|delete|remove|bash|shell|command/.test(text)) score -= 50; - } else if (intent === "webSearch" || intent === "browsePage") { - const preferUrlFetch = wantsUrlFetch(userText); - if (isContextMemoryTool(tool) || isMetaOrInfrastructureTool(tool)) score -= 180; - if (intent === "browsePage" || preferUrlFetch) { - if (!isUrlFetchTool(tool)) score -= 60; - if (/webfetch|web_fetch|fetch|browse|browse_page|read_url|url_fetch|page/.test(name)) - score += 140; - if (propNames.has("url") || propNames.has("uri")) score += 90; - if (/fetch|browse|url|web page|page content|extract.*url|read.*url/.test(text)) score += 55; - if ( - /websearch|web_search|search/.test(name) && - !(propNames.has("url") || propNames.has("uri")) - ) - score -= 80; - } - if (intent === "webSearch" && !isWebSearchTool(tool)) score -= 60; - if ( - intent === "browsePage" && - /\b(websearch|web_search|search)\b/.test(name) && - !(propNames.has("url") || propNames.has("uri")) - ) - score -= 120; - if (["web_search", "websearch", "search"].includes(name)) score += 100; - if (propNames.has("query") || propNames.has("search")) score += 50; - if (/web.*search|search.*web|internet|browse/.test(text)) score += 25; - if (/file|bash|shell|command|write|edit/.test(text)) score -= 50; - } - - return score; -} - -function pickDeclaredToolForIntent( - intent: NativeToolIntent, - toolRegistry: GrokToolRegistry -): string | null { - let best: { name: string; score: number } | null = null; - for (const tool of toolRegistry.toolsByName.values()) { - const score = toolScore(tool, intent, { lastUserText: toolRegistry.lastUserText }); - if (score <= 0) continue; - if (!best || score > best.score) best = { name: tool.name, score }; - } - return best?.name || null; -} - -function mapGrokNativeToolToOpenAI( - resp: GrokStreamResponse, - toolRegistry: GrokToolRegistry -): OpenAIToolCall | null { - if (!toolRegistry.enabled || !resp.toolUsageCard) return null; - const card = resp.toolUsageCard as Record; - const id = resp.toolUsageCardId || String(card.toolUsageCardId || `call_${crypto.randomUUID()}`); - - const bash = card.bash as { args?: Record } | undefined; - if (bash?.args) { - const name = pickDeclaredToolForIntent("bash", toolRegistry); - if (name) { - const args = adaptArgumentsToDeclaredTool(name, bash.args, toolRegistry, "bash", { - preserveUnknownArgs: false, - }); - if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; - return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; - } - } - - const readFile = (card.readFile || card.read_file) as - | { args?: Record } - | undefined; - if (readFile?.args) { - const rawPath = readFile.args.filePath || readFile.args.file_path || readFile.args.path; - const name = pickDeclaredToolForIntent("readFile", toolRegistry); - if (name && typeof rawPath === "string") { - const userOffset = extractNumericUserParam(toolRegistry.lastUserText, ["offset"]); - const userLimit = extractNumericUserParam(toolRegistry.lastUserText, [ - "limit", - "limite", - "límite", - ]); - const rawArgs = { - ...readFile.args, - ...(userOffset !== undefined ? { offset: userOffset } : {}), - ...(userLimit !== undefined ? { limit: userLimit } : {}), - filePath: rawPath, - file_path: rawPath, - path: rawPath, - }; - const args = adaptArgumentsToDeclaredTool(name, rawArgs, toolRegistry, "readFile", { - preserveUnknownArgs: false, - }); - if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; - return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; - } - } - - const webSearch = card.webSearch as { args?: Record } | undefined; - if (webSearch?.args) { - const name = pickDeclaredToolForIntent("webSearch", toolRegistry); - if (name) { - const requestedUrl = wantsUrlFetch(toolRegistry.lastUserText) - ? extractFirstUrl(toolRegistry.lastUserText) - : undefined; - const args = adaptArgumentsToDeclaredTool( - name, - requestedUrl ? { ...webSearch.args, url: requestedUrl, uri: requestedUrl } : webSearch.args, - toolRegistry, - requestedUrl ? "webFetch" : "webSearch", - { preserveUnknownArgs: false } - ); - if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; - return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; - } - } - - const browsePage = (card.browsePage || card.browse_page) as - | { args?: Record } - | undefined; - if (browsePage?.args) { - const url = firstString(browsePage.args.url, browsePage.args.uri); - const name = pickDeclaredToolForIntent("browsePage", toolRegistry); - if (name && url) { - const args = adaptArgumentsToDeclaredTool( - name, - { ...browsePage.args, url, uri: url, input: url }, - toolRegistry, - "browsePage", - { preserveUnknownArgs: false } - ); - if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; - return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; - } - } - - return null; -} - -// ─── NDJSON stream types ──────────────────────────────────────────────────── - -interface GrokStreamResponse { - token?: string; - isThinking?: boolean; - reasoning?: string; - reasoningContent?: string; - reasoning_content?: string; - thinking?: string; - thought?: string; - responseId?: string; - messageTag?: string; - messageStepId?: number; - toolUsageCardId?: string; - toolUsageCard?: { - toolUsageCardId?: string; - bash?: { args?: Record }; - readFile?: { args?: Record }; - read_file?: { args?: Record }; - webSearch?: { args?: Record }; - browsePage?: { args?: Record }; - browse_page?: { args?: Record }; - }; - webSearchResults?: { - results?: Array>; - }; - llmInfo?: { modelHash?: string }; - modelResponse?: { - message?: string; - reasoning?: string; - reasoningContent?: string; - reasoning_content?: string; - thinking?: string; - thought?: string; - responseId?: string; - generatedImageUrls?: string[]; - metadata?: { llm_info?: { modelHash?: string } }; - pipelineToken?: string; - }; -} - -interface GrokStreamEvent { - result?: { response?: GrokStreamResponse }; - error?: { message?: string; code?: string }; -} - // ─── NDJSON parsing ───────────────────────────────────────────────────────── async function* readGrokNdjsonEvents( @@ -1005,141 +152,6 @@ async function* readGrokNdjsonEvents( } } -// ─── Grok markup cleanup ──────────────────────────────────────────────────── - -const BLOCKED_GROK_MARKUP = [ - { start: "" }, -] as const; - -const PARTIAL_GROK_MARKER_KEEP = 32; - -function stripLooseGrokMarkup(text: string): string { - return text - .replace(/<\/?xai:[^>]*>/g, "") - .replace(/<\/?grok:[^>]*>/g, "") - .replace(/<\/?argument\b[^>]*>/g, "") - .replace(//g, ""); -} - -class GrokMarkupFilter { - private buffer = ""; - private suppressedUntil: string | null = null; - - feed(text: string): string { - if (!text) return ""; - this.buffer += text; - return this.drain(false); - } - - flush(): string { - const out = this.drain(true); - this.buffer = ""; - this.suppressedUntil = null; - return out; - } - - private drain(flush: boolean): string { - let out = ""; - - while (this.buffer) { - if (this.suppressedUntil) { - const endIdx = this.buffer.indexOf(this.suppressedUntil); - if (endIdx < 0) { - this.buffer = this.buffer.slice(this.longestEndPrefixStart(this.suppressedUntil)); - return out; - } - this.buffer = this.buffer.slice(endIdx + this.suppressedUntil.length); - this.suppressedUntil = null; - continue; - } - - let nextStart = -1; - let nextEnd = ""; - for (const marker of BLOCKED_GROK_MARKUP) { - const idx = this.buffer.indexOf(marker.start); - if (idx >= 0 && (nextStart < 0 || idx < nextStart)) { - nextStart = idx; - nextEnd = marker.end; - } - } - - if (nextStart < 0) { - if (!flush) { - const lastLt = this.buffer.lastIndexOf("<"); - if (lastLt >= 0 && this.buffer.length - lastLt <= PARTIAL_GROK_MARKER_KEEP) { - out += stripLooseGrokMarkup(this.buffer.slice(0, lastLt)); - this.buffer = this.buffer.slice(lastLt); - return out; - } - } - out += stripLooseGrokMarkup(this.buffer); - this.buffer = ""; - return out; - } - - out += stripLooseGrokMarkup(this.buffer.slice(0, nextStart)); - this.buffer = this.buffer.slice(nextStart); - const endIdx = this.buffer.indexOf(nextEnd); - const openTagEndIdx = this.buffer.indexOf(">"); - if (openTagEndIdx >= 0 && /\/\s*>$/.test(this.buffer.slice(0, openTagEndIdx + 1))) { - this.buffer = this.buffer.slice(openTagEndIdx + 1); - continue; - } - if (endIdx < 0) { - this.suppressedUntil = nextEnd; - this.buffer = this.buffer.slice(this.longestEndPrefixStart(nextEnd)); - return out; - } - this.buffer = this.buffer.slice(endIdx + nextEnd.length); - } - - return out; - } - - private longestEndPrefixStart(end: string): number { - const max = Math.min(this.buffer.length, end.length - 1); - for (let len = max; len > 0; len--) { - if (this.buffer.slice(-len) === end.slice(0, len)) return this.buffer.length - len; - } - return this.buffer.length; - } -} - -function cleanGrokText(text: string): string { - const filter = new GrokMarkupFilter(); - return filter.feed(text) + filter.flush(); -} - -function cleanGrokContentText(text: string): string { - return cleanGrokText(text); -} - -function cleanGrokThinkingText(resp: GrokStreamResponse): string { - const text = resp.token || ""; - const cleaned = cleanGrokText(text); - const trimmed = cleaned.trim(); - if (!trimmed) return ""; - const isGenericOpeningHeader = - resp.messageTag === "header" && - resp.messageStepId === 0 && - /^(?:\.{3}|thinking(?: about your request)?)$/i.test(trimmed); - if (isGenericOpeningHeader) return ""; - if (resp.messageTag === "header") return `${trimmed}\n`; - if (resp.messageTag === "summary") return `${trimmed}\n`; - return cleaned; -} - -function extractStructuredReasoning(value: object | undefined): string { - if (!value) return ""; - const record = value as Record; - for (const key of ["reasoning", "reasoningContent", "reasoning_content", "thinking", "thought"]) { - const candidate = record[key]; - if (typeof candidate === "string" && candidate.trim()) return cleanGrokText(candidate); - } - return ""; -} - // ─── Content extraction ───────────────────────────────────────────────────── interface ContentChunk { @@ -1654,8 +666,7 @@ export class GrokWebExecutor extends BaseExecutor { upstreamExtraHeaders, }: ExecuteInput) { const messages = (body as Record).messages as - | Array> - | undefined; + Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { const errResp = new Response( JSON.stringify({ diff --git a/open-sse/executors/grok-web/native-tools.ts b/open-sse/executors/grok-web/native-tools.ts new file mode 100644 index 0000000000..ba003072a6 --- /dev/null +++ b/open-sse/executors/grok-web/native-tools.ts @@ -0,0 +1,182 @@ +// Grok native-tool selection + native->OpenAI mapping (pure). Verbatim from grok-web.ts. +import type { GrokStreamResponse } from "./types.ts"; +import { + type OpenAIToolCall, + type GrokToolRegistry, + type GrokFunctionToolSummary, + type NativeToolIntent, + type ToolBridgeContext, + semanticToolKey, + extractFirstUrl, + wantsUrlFetch, + getSchemaProperties, + isTerminalTool, + isFileReadTool, + isUrlFetchTool, + isWebSearchTool, + isContextMemoryTool, + isMetaOrInfrastructureTool, + firstString, + extractNumericUserParam, + adaptArgumentsToDeclaredTool, +} from "./tool-bridge.ts"; + +export function toolScore( + tool: GrokFunctionToolSummary, + intent: NativeToolIntent, + context: ToolBridgeContext +): number { + const name = tool.name.toLowerCase(); + const description = (tool.description || "").toLowerCase(); + const properties = getSchemaProperties(tool.parameters); + const propNames = new Set(Object.keys(properties).map((key) => key.toLowerCase())); + const text = `${name} ${description}`; + const userText = context.lastUserText.toLowerCase(); + let score = 0; + + if (intent === "bash") { + if (!isTerminalTool(tool)) score -= 80; + if (name === "bash") score += 100; + if (["shell", "terminal", "run_command", "execute_command", "exec", "command"].includes(name)) + score += 80; + if (propNames.has("command") || propNames.has("cmd")) score += 60; + if (/bash|shell|terminal|command|execute|run/.test(text)) score += 25; + if (/read|search|grep|web|http|browser|context|note|memory/.test(name)) score -= 50; + } else if (intent === "readFile") { + if (!isFileReadTool(tool)) score -= 60; + if (["read", "read_file", "readfile", "file_read"].includes(name)) score += 100; + if (propNames.has("filepath") || propNames.has("file_path") || propNames.has("path")) + score += 50; + if (/read.*file|file.*read|filesystem/.test(text)) score += 25; + if (/write|edit|delete|remove|bash|shell|command/.test(text)) score -= 50; + } else if (intent === "webSearch" || intent === "browsePage") { + const preferUrlFetch = wantsUrlFetch(userText); + if (isContextMemoryTool(tool) || isMetaOrInfrastructureTool(tool)) score -= 180; + if (intent === "browsePage" || preferUrlFetch) { + if (!isUrlFetchTool(tool)) score -= 60; + if (/webfetch|web_fetch|fetch|browse|browse_page|read_url|url_fetch|page/.test(name)) + score += 140; + if (propNames.has("url") || propNames.has("uri")) score += 90; + if (/fetch|browse|url|web page|page content|extract.*url|read.*url/.test(text)) score += 55; + if ( + /websearch|web_search|search/.test(name) && + !(propNames.has("url") || propNames.has("uri")) + ) + score -= 80; + } + if (intent === "webSearch" && !isWebSearchTool(tool)) score -= 60; + if ( + intent === "browsePage" && + /\b(websearch|web_search|search)\b/.test(name) && + !(propNames.has("url") || propNames.has("uri")) + ) + score -= 120; + if (["web_search", "websearch", "search"].includes(name)) score += 100; + if (propNames.has("query") || propNames.has("search")) score += 50; + if (/web.*search|search.*web|internet|browse/.test(text)) score += 25; + if (/file|bash|shell|command|write|edit/.test(text)) score -= 50; + } + + return score; +} + +export function pickDeclaredToolForIntent( + intent: NativeToolIntent, + toolRegistry: GrokToolRegistry +): string | null { + let best: { name: string; score: number } | null = null; + for (const tool of toolRegistry.toolsByName.values()) { + const score = toolScore(tool, intent, { lastUserText: toolRegistry.lastUserText }); + if (score <= 0) continue; + if (!best || score > best.score) best = { name: tool.name, score }; + } + return best?.name || null; +} + +export function mapGrokNativeToolToOpenAI( + resp: GrokStreamResponse, + toolRegistry: GrokToolRegistry +): OpenAIToolCall | null { + if (!toolRegistry.enabled || !resp.toolUsageCard) return null; + const card = resp.toolUsageCard as Record; + const id = resp.toolUsageCardId || String(card.toolUsageCardId || `call_${crypto.randomUUID()}`); + + const bash = card.bash as { args?: Record } | undefined; + if (bash?.args) { + const name = pickDeclaredToolForIntent("bash", toolRegistry); + if (name) { + const args = adaptArgumentsToDeclaredTool(name, bash.args, toolRegistry, "bash", { + preserveUnknownArgs: false, + }); + if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; + return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; + } + } + + const readFile = (card.readFile || card.read_file) as + { args?: Record } | undefined; + if (readFile?.args) { + const rawPath = readFile.args.filePath || readFile.args.file_path || readFile.args.path; + const name = pickDeclaredToolForIntent("readFile", toolRegistry); + if (name && typeof rawPath === "string") { + const userOffset = extractNumericUserParam(toolRegistry.lastUserText, ["offset"]); + const userLimit = extractNumericUserParam(toolRegistry.lastUserText, [ + "limit", + "limite", + "límite", + ]); + const rawArgs = { + ...readFile.args, + ...(userOffset !== undefined ? { offset: userOffset } : {}), + ...(userLimit !== undefined ? { limit: userLimit } : {}), + filePath: rawPath, + file_path: rawPath, + path: rawPath, + }; + const args = adaptArgumentsToDeclaredTool(name, rawArgs, toolRegistry, "readFile", { + preserveUnknownArgs: false, + }); + if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; + return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; + } + } + + const webSearch = card.webSearch as { args?: Record } | undefined; + if (webSearch?.args) { + const name = pickDeclaredToolForIntent("webSearch", toolRegistry); + if (name) { + const requestedUrl = wantsUrlFetch(toolRegistry.lastUserText) + ? extractFirstUrl(toolRegistry.lastUserText) + : undefined; + const args = adaptArgumentsToDeclaredTool( + name, + requestedUrl ? { ...webSearch.args, url: requestedUrl, uri: requestedUrl } : webSearch.args, + toolRegistry, + requestedUrl ? "webFetch" : "webSearch", + { preserveUnknownArgs: false } + ); + if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; + return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; + } + } + + const browsePage = (card.browsePage || card.browse_page) as + { args?: Record } | undefined; + if (browsePage?.args) { + const url = firstString(browsePage.args.url, browsePage.args.uri); + const name = pickDeclaredToolForIntent("browsePage", toolRegistry); + if (name && url) { + const args = adaptArgumentsToDeclaredTool( + name, + { ...browsePage.args, url, uri: url, input: url }, + toolRegistry, + "browsePage", + { preserveUnknownArgs: false } + ); + if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) return null; + return { id, type: "function", function: { name, arguments: JSON.stringify(args) } }; + } + } + + return null; +} diff --git a/open-sse/executors/grok-web/text-cleanup.ts b/open-sse/executors/grok-web/text-cleanup.ts new file mode 100644 index 0000000000..cbca2b623f --- /dev/null +++ b/open-sse/executors/grok-web/text-cleanup.ts @@ -0,0 +1,137 @@ +// Grok markup cleanup (pure). Extracted verbatim from grok-web.ts. +import type { GrokStreamResponse } from "./types.ts"; + +// ─── Grok markup cleanup ──────────────────────────────────────────────────── + +export const BLOCKED_GROK_MARKUP = [ + { start: "" }, +] as const; + +export const PARTIAL_GROK_MARKER_KEEP = 32; + +export function stripLooseGrokMarkup(text: string): string { + return text + .replace(/<\/?xai:[^>]*>/g, "") + .replace(/<\/?grok:[^>]*>/g, "") + .replace(/<\/?argument\b[^>]*>/g, "") + .replace(//g, ""); +} + +export class GrokMarkupFilter { + private buffer = ""; + private suppressedUntil: string | null = null; + + feed(text: string): string { + if (!text) return ""; + this.buffer += text; + return this.drain(false); + } + + flush(): string { + const out = this.drain(true); + this.buffer = ""; + this.suppressedUntil = null; + return out; + } + + private drain(flush: boolean): string { + let out = ""; + + while (this.buffer) { + if (this.suppressedUntil) { + const endIdx = this.buffer.indexOf(this.suppressedUntil); + if (endIdx < 0) { + this.buffer = this.buffer.slice(this.longestEndPrefixStart(this.suppressedUntil)); + return out; + } + this.buffer = this.buffer.slice(endIdx + this.suppressedUntil.length); + this.suppressedUntil = null; + continue; + } + + let nextStart = -1; + let nextEnd = ""; + for (const marker of BLOCKED_GROK_MARKUP) { + const idx = this.buffer.indexOf(marker.start); + if (idx >= 0 && (nextStart < 0 || idx < nextStart)) { + nextStart = idx; + nextEnd = marker.end; + } + } + + if (nextStart < 0) { + if (!flush) { + const lastLt = this.buffer.lastIndexOf("<"); + if (lastLt >= 0 && this.buffer.length - lastLt <= PARTIAL_GROK_MARKER_KEEP) { + out += stripLooseGrokMarkup(this.buffer.slice(0, lastLt)); + this.buffer = this.buffer.slice(lastLt); + return out; + } + } + out += stripLooseGrokMarkup(this.buffer); + this.buffer = ""; + return out; + } + + out += stripLooseGrokMarkup(this.buffer.slice(0, nextStart)); + this.buffer = this.buffer.slice(nextStart); + const endIdx = this.buffer.indexOf(nextEnd); + const openTagEndIdx = this.buffer.indexOf(">"); + if (openTagEndIdx >= 0 && /\/\s*>$/.test(this.buffer.slice(0, openTagEndIdx + 1))) { + this.buffer = this.buffer.slice(openTagEndIdx + 1); + continue; + } + if (endIdx < 0) { + this.suppressedUntil = nextEnd; + this.buffer = this.buffer.slice(this.longestEndPrefixStart(nextEnd)); + return out; + } + this.buffer = this.buffer.slice(endIdx + nextEnd.length); + } + + return out; + } + + private longestEndPrefixStart(end: string): number { + const max = Math.min(this.buffer.length, end.length - 1); + for (let len = max; len > 0; len--) { + if (this.buffer.slice(-len) === end.slice(0, len)) return this.buffer.length - len; + } + return this.buffer.length; + } +} + +export function cleanGrokText(text: string): string { + const filter = new GrokMarkupFilter(); + return filter.feed(text) + filter.flush(); +} + +export function cleanGrokContentText(text: string): string { + return cleanGrokText(text); +} + +export function cleanGrokThinkingText(resp: GrokStreamResponse): string { + const text = resp.token || ""; + const cleaned = cleanGrokText(text); + const trimmed = cleaned.trim(); + if (!trimmed) return ""; + const isGenericOpeningHeader = + resp.messageTag === "header" && + resp.messageStepId === 0 && + /^(?:\.{3}|thinking(?: about your request)?)$/i.test(trimmed); + if (isGenericOpeningHeader) return ""; + if (resp.messageTag === "header") return `${trimmed}\n`; + if (resp.messageTag === "summary") return `${trimmed}\n`; + return cleaned; +} + +export function extractStructuredReasoning(value: object | undefined): string { + if (!value) return ""; + const record = value as Record; + for (const key of ["reasoning", "reasoningContent", "reasoning_content", "thinking", "thought"]) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.trim()) return cleanGrokText(candidate); + } + return ""; +} diff --git a/open-sse/executors/grok-web/tool-bridge.ts b/open-sse/executors/grok-web/tool-bridge.ts new file mode 100644 index 0000000000..90a059f6e5 --- /dev/null +++ b/open-sse/executors/grok-web/tool-bridge.ts @@ -0,0 +1,666 @@ +// OpenAI <-> Grok tool-call translation (pure). Extracted verbatim from grok-web.ts. +import type { GrokStreamResponse } from "./types.ts"; + +// ─── OpenAI message → Grok query translation ─────────────────────────────── + +export interface OpenAIToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +export interface GrokToolRegistry { + enabled: boolean; + toolsByName: Map; + lastUserText: string; + executedToolKeys: Set; + completedToolCalls: string[]; +} + +export interface GrokFunctionToolSummary { + name: string; + description?: string; + parameters: unknown; +} + +export type NativeToolIntent = "bash" | "readFile" | "webSearch" | "browsePage"; + +export interface ToolBridgeContext { + lastUserText: string; +} + +export function stripInjectedRuntimeReminders(text: string): string { + return text + .replace(/\n?---\s*\n\s*[\s\S]*?<\/internal_reminder>/gi, "") + .replace(/[\s\S]*?<\/internal_reminder>/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function extractTextContent(msg: Record): string { + if (typeof msg.content === "string") return stripInjectedRuntimeReminders(msg.content); + if (Array.isArray(msg.content)) { + return stripInjectedRuntimeReminders( + (msg.content as Array>) + .filter((c) => c.type === "text") + .map((c) => String(c.text || "")) + .join(" ") + ); + } + return ""; +} + +export function getLastUserText(messages: Array>): string { + for (let i = messages.length - 1; i >= 0; i--) { + if (String(messages[i].role || "") === "user") return extractTextContent(messages[i]); + } + return ""; +} + +export function normalizeToolArgumentObject(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : { input: value }; + } catch { + return { input: value }; + } + } + return {}; +} + +export function stableJson(value: unknown): string { + if (!value || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; +} + +export function toolCallKey(name: string, args: unknown): string { + return `${name}:${stableJson(normalizeToolArgumentObject(args))}`; +} + +export function normalizeShellCommand(command: string): string { + return command.trim().replace(/\s+/g, " "); +} + +export function normalizePathValue(path: string): string { + return path.trim().replace(/^['"]|['"]$/g, ""); +} + +export function normalizeQueryValue(query: string): string { + return query.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function semanticToolKey(name: string, args: unknown): string { + const normalizedName = name.trim(); + const record = normalizeToolArgumentObject(args); + const command = firstString(record.command, record.cmd, record.shell); + if (command) return `${normalizedName}:command:${normalizeShellCommand(command)}`; + + const url = firstString(record.url, record.uri); + if (url) return `${normalizedName}:url:${normalizePathValue(url)}`; + + const path = firstString(record.filePath, record.file_path, record.path, record.filename); + if (path) return `${normalizedName}:path:${normalizePathValue(path)}`; + + const query = firstString(record.query, record.search); + if (query) return `${normalizedName}:query:${normalizeQueryValue(query)}`; + + return toolCallKey(normalizedName, record); +} + +export function summarizeCompletedToolCall(name: string, args: unknown): string { + const record = normalizeToolArgumentObject(args); + const command = firstString(record.command, record.cmd, record.shell); + if (command) return `${name}(command=${JSON.stringify(normalizeShellCommand(command))})`; + const url = firstString(record.url, record.uri); + if (url) return `${name}(url=${JSON.stringify(normalizePathValue(url))})`; + const path = firstString(record.filePath, record.file_path, record.path, record.filename); + if (path) return `${name}(path=${JSON.stringify(normalizePathValue(path))})`; + const query = firstString(record.query, record.search); + if (query) return `${name}(query=${JSON.stringify(normalizeQueryValue(query))})`; + return `${name}(${stableJson(record)})`; +} + +export function getExecutedToolState(messages: Array>): { + keys: Set; + summaries: string[]; +} { + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (String(messages[i].role || "") === "user") { + lastUserIdx = i; + break; + } + } + const callsById = new Map }>(); + const executed = new Set(); + const summaries: string[] = []; + for (let i = Math.max(0, lastUserIdx + 1); i < messages.length; i++) { + const msg = messages[i]; + if (String(msg.role || "") === "assistant" && Array.isArray(msg.tool_calls)) { + for (const call of msg.tool_calls as Array>) { + const id = typeof call.id === "string" ? call.id : ""; + const fn = call.function; + if (!id || !fn || typeof fn !== "object") continue; + const fnRecord = fn as Record; + const name = typeof fnRecord.name === "string" ? fnRecord.name : ""; + if (!name) continue; + callsById.set(id, { name, args: normalizeToolArgumentObject(fnRecord.arguments) }); + } + } + if (String(msg.role || "") === "tool") { + const id = typeof msg.tool_call_id === "string" ? msg.tool_call_id : ""; + const call = id ? callsById.get(id) : null; + if (call) { + const key = semanticToolKey(call.name, call.args); + executed.add(key); + const summary = summarizeCompletedToolCall(call.name, call.args); + if (!summaries.includes(summary)) summaries.push(summary); + } + } + } + return { keys: executed, summaries }; +} + +export function extractFirstUrl(text: string): string | undefined { + const match = text.match(/https?:\/\/[^\s)\]}>"']+/i); + if (match?.[0]) return match[0].replace(/[.,;:!?]+$/, ""); + const domain = text.match(/(?:^|\s)((?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s)\]}>"']*)?)/i)?.[1]; + return domain ? `https://${domain.replace(/[.,;:!?]+$/, "")}` : undefined; +} + +export function wantsUrlFetch(text: string): boolean { + return ( + /\b(webfetch|web_fetch|fetch|browse|open|read|lee|abre|extrae|investiga|analiza|resume|summarize|de qu[eé] va)\b/i.test( + text + ) && !!extractFirstUrl(text) + ); +} + +export function forcedToolChoiceName(toolChoice: unknown): string | null { + if (!toolChoice || typeof toolChoice !== "object") return null; + const record = toolChoice as Record; + if (record.type !== "function" || !record.function || typeof record.function !== "object") + return null; + const name = (record.function as Record).name; + return typeof name === "string" && name.trim() ? name.trim() : null; +} + +export function parseOpenAIMessages( + messages: Array>, + beforeLatestUser = "" +): string { + const parts: string[] = []; + let lastUserIdx = -1; + let lastUserSourceIdx = -1; + + for (let i = messages.length - 1; i >= 0; i--) { + if (String(messages[i].role || "") === "user") { + lastUserSourceIdx = i; + break; + } + } + + // Extract text from each message + const extracted: Array<{ role: string; text: string }> = []; + + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const msg = messages[msgIdx]; + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + + let content = extractTextContent(msg); + if (role === "tool") { + if (msgIdx < lastUserSourceIdx) continue; + const toolName = typeof msg.name === "string" ? msg.name : "unknown_tool"; + const toolCallId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "unknown_call"; + content = `CLIENT TOOL RESULT from caller runtime for ${toolName} (${toolCallId}). Use this result to answer; do not call the same tool again:\n${content}`; + } else if (role === "assistant" && Array.isArray(msg.tool_calls)) { + if (msgIdx < lastUserSourceIdx) continue; + const calls = (msg.tool_calls as Array>).map((call) => ({ + id: call.id, + function: call.function, + })); + content = [content, `Previous assistant tool calls: ${JSON.stringify(calls)}`] + .filter(Boolean) + .join("\n"); + } + if (!content.trim()) continue; + extracted.push({ role, text: content }); + } + + // Find last user message index + for (let i = extracted.length - 1; i >= 0; i--) { + if (extracted[i].role === "user") { + lastUserIdx = i; + break; + } + } + + // Build combined message — last user message is raw, others are prefixed + for (let i = 0; i < extracted.length; i++) { + const { role, text } = extracted[i]; + if (i === lastUserIdx) { + parts.push(text); + } else { + parts.push(`${role}: ${text}`); + } + } + + if (beforeLatestUser.trim()) { + parts.push(beforeLatestUser.trim()); + } + + return parts.join("\n\n"); +} + +export function buildGrokToolRegistry(body: Record): GrokToolRegistry { + const tools = Array.isArray(body.tools) ? (body.tools as Array>) : []; + const messages = Array.isArray(body.messages) + ? (body.messages as Array>) + : []; + const lastUserText = getLastUserText(messages); + const executedToolState = getExecutedToolState(messages); + const toolChoice = body.tool_choice ?? "auto"; + + if (toolChoice === "none") { + return { + enabled: false, + toolsByName: new Map(), + lastUserText, + executedToolKeys: executedToolState.keys, + completedToolCalls: executedToolState.summaries, + }; + } + + const functionTools: GrokFunctionToolSummary[] = tools + .map((tool) => { + const fn = tool?.function; + if (tool?.type !== "function" || !fn || typeof fn !== "object") return null; + const record = fn as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + if (!name) return null; + return { + name, + ...(typeof record.description === "string" ? { description: record.description } : {}), + parameters: record.parameters || { type: "object", properties: {} }, + }; + }) + .filter((tool): tool is GrokFunctionToolSummary => Boolean(tool)); + const forcedName = forcedToolChoiceName(toolChoice); + const visibleTools = forcedName + ? functionTools.filter((tool) => tool.name === forcedName) + : functionTools; + + return { + enabled: visibleTools.length > 0, + toolsByName: new Map(visibleTools.map((tool) => [tool.name, tool])), + lastUserText, + executedToolKeys: executedToolState.keys, + completedToolCalls: executedToolState.summaries, + }; +} + +export function getSchemaProperties(parameters: unknown): Record { + if (!parameters || typeof parameters !== "object") return {}; + const properties = (parameters as Record).properties; + return properties && typeof properties === "object" + ? (properties as Record) + : {}; +} + +export function getSchemaRequired(parameters: unknown): string[] { + if (!parameters || typeof parameters !== "object") return []; + const required = (parameters as Record).required; + return Array.isArray(required) + ? required.filter((key): key is string => typeof key === "string") + : []; +} + +export function formatToolArgsSummary(parameters: unknown): string { + const properties = getSchemaProperties(parameters); + const propNames = Object.keys(properties); + const required = getSchemaRequired(parameters); + const segments: string[] = []; + if (propNames.length > 0) segments.push(`args=${propNames.join(",")}`); + if (required.length > 0) segments.push(`required=${required.join(",")}`); + return segments.length > 0 ? ` (${segments.join("; ")})` : ""; +} + +export function toolText(tool: GrokFunctionToolSummary): string { + return `${tool.name} ${tool.description || ""}`.toLowerCase(); +} + +export function hasAnyProperty(tool: GrokFunctionToolSummary, names: string[]): boolean { + const properties = getSchemaProperties(tool.parameters); + const lowerProps = new Set(Object.keys(properties).map((key) => key.toLowerCase())); + return names.some((name) => lowerProps.has(name.toLowerCase())); +} + +export function isTerminalTool(tool: GrokFunctionToolSummary): boolean { + if (isMetaOrInfrastructureTool(tool)) return false; + const text = toolText(tool); + const name = tool.name.toLowerCase(); + const explicitName = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/.test( + name + ); + const explicitText = + /\b(?:run|execute).{0,24}\b(?:shell|bash|terminal|command)\b|\b(?:shell|bash|terminal)\b/.test( + text + ); + return explicitName || (hasAnyProperty(tool, ["command", "cmd", "shell"]) && explicitText); +} + +export function isFileReadTool(tool: GrokFunctionToolSummary): boolean { + const text = toolText(tool); + return ( + hasAnyProperty(tool, ["filePath", "file_path", "path"]) && + /\b(read|file|filesystem|open)\b/.test(text) && + !/\b(write|edit|patch|delete|remove|grep|search|bash|shell|command)\b/.test(text) + ); +} + +export function isUrlFetchTool(tool: GrokFunctionToolSummary): boolean { + const text = toolText(tool); + const name = tool.name.toLowerCase(); + const explicitName = + /\b(webfetch|web.fetch|fetch_url|url_fetch|read_url|browse_page|browsepage)\b/.test(name); + const explicitUrlText = + /\b(?:fetch|browse|read).{0,32}\b(?:url|uri|web page|page content)\b|\b(?:url|uri|web page|page content).{0,32}\b(?:fetch|browse|read)\b/.test( + text + ); + return ( + explicitName || + (!isMetaOrInfrastructureTool(tool) && hasAnyProperty(tool, ["url", "uri"]) && explicitUrlText) + ); +} + +export function isWebSearchTool(tool: GrokFunctionToolSummary): boolean { + const text = toolText(tool); + return ( + hasAnyProperty(tool, ["query", "search"]) && + /\b(web|internet|exa|browser|browse|serp)\b/.test(text) && + !isMetaOrInfrastructureTool(tool) && + !isContextMemoryTool(tool) + ); +} + +export function isContextMemoryTool(tool: GrokFunctionToolSummary): boolean { + const text = toolText(tool); + return /\b(ctx_|memory|memories|conversation history|session notes|git commits|project memories|context.db|magic context)\b/.test( + text + ); +} + +export function isMetaOrInfrastructureTool(tool: GrokFunctionToolSummary): boolean { + const text = toolText(tool); + return /\b(mcp|mcpproxy|upstream|registry|registries|quarantine|oauth|cache key|token usage|session notes|conversation transcript|handoff|context management|memory|memories|lsp|language server|plan file|server management|tool discovery|tools? using bm25)\b/.test( + text + ); +} + +export function baseToolOrderScore(tool: GrokFunctionToolSummary): number { + if (isUrlFetchTool(tool)) return 90; + if (isWebSearchTool(tool)) return 85; + if (isFileReadTool(tool)) return 75; + if (isTerminalTool(tool)) return 70; + if (/\b(glob|grep|search files?|file search|content search)\b/.test(toolText(tool))) return 60; + if (/\b(edit|write|patch|modify|apply)\b/.test(toolText(tool))) return 50; + if (/\b(task|agent|delegate|subagent)\b/.test(toolText(tool))) return 40; + if (isMetaOrInfrastructureTool(tool)) return 10; + if (isContextMemoryTool(tool)) return 20; + return 30; +} + +export function latestUserIntentScore(tool: GrokFunctionToolSummary, lastUserText: string): number { + const user = lastUserText.toLowerCase(); + const hasPath = /(?:^|\s|["'`])(?:~|\.?\.?\/|\/)[^\s"'`]+/.test(lastUserText); + const hasUrl = !!extractFirstUrl(lastUserText); + const asksLineCount = /\b(l[ií]neas?|line count|cu[aá]ntas? l[ií]neas?|wc\s+-l)\b/.test(user); + const asksFileContent = + /\b(lee|leer|read|archivo|file|json|config|modelo|default|por defecto|de qu[eé] va|consiste|contenido)\b/.test( + user + ) && hasPath; + const asksContext = + /\b(contexto|memoria|historial|conversation history|project memories|ctx_|memory|memories|recordabas?)\b/.test( + user + ); + const asksWeb = + !asksContext && + /\b(web|internet|fuente|oficial|release|versi[oó]n|ubuntu|latest|actual|contrasta|busca|search)\b/.test( + user + ); + let score = 0; + + if (asksFileContent && isFileReadTool(tool)) score += 160; + if (asksFileContent && isTerminalTool(tool)) score += asksLineCount ? 70 : 20; + if (asksLineCount && isTerminalTool(tool)) score += 120; + if (asksLineCount && isFileReadTool(tool)) score += asksFileContent ? 90 : 30; + if (asksWeb && !hasUrl && isWebSearchTool(tool)) score += 170; + if (asksWeb && !hasUrl && isUrlFetchTool(tool)) score += 35; + if (asksWeb && isContextMemoryTool(tool)) score -= 120; + if (asksContext && isContextMemoryTool(tool)) score += 170; + if (asksContext && isWebSearchTool(tool)) score -= 80; + + if (isContextMemoryTool(tool) && (asksFileContent || asksWeb)) score -= 80; + return score; +} + +export function orderedToolsForManifest( + toolRegistry: GrokToolRegistry +): Array<{ tool: GrokFunctionToolSummary; score: number }> { + return [...toolRegistry.toolsByName.values()] + .map((tool, index) => ({ + tool, + score: latestUserIntentScore(tool, toolRegistry.lastUserText) + baseToolOrderScore(tool), + meta: isMetaOrInfrastructureTool(tool), + index, + })) + .sort((a, b) => b.score - a.score || Number(a.meta) - Number(b.meta) || a.index - b.index) + .map(({ tool, score }) => ({ tool, score })); +} + +export function formatToolManifestEntry(tool: GrokFunctionToolSummary, rank: number): string { + const desc = tool.description ? `\n description: ${tool.description}` : ""; + const args = formatToolArgsSummary(tool.parameters).trim(); + return `${rank}. name: ${tool.name}${args ? `\n ${args.slice(1, -1)}` : ""}${desc ? desc.replace(/\n /g, "\n ") : ""}`; +} + +export function buildClientToolManifest( + toolRegistry: GrokToolRegistry, + toolChoice: unknown +): string { + if (!toolRegistry.enabled) return ""; + const orderedTools = orderedToolsForManifest(toolRegistry); + const lines = [ + 'CLIENT_TOOLS: use this caller-runtime tool list as the tool interface for this request. To call one, respond only with {"name":"exact_tool_name","arguments":{...}}. After tool results, answer normally.', + `tool_choice=${JSON.stringify(toolChoice ?? "auto")}`, + ...(toolRegistry.completedToolCalls.length > 0 + ? [ + "completed_tool_calls:", + ...toolRegistry.completedToolCalls.map((summary) => `- ${summary}`), + "Do not repeat completed tool calls unless a different result is required; use their tool results to answer.", + ] + : []), + "tools (priority order for this request):", + ...orderedTools.map(({ tool }, index) => formatToolManifestEntry(tool, index + 1)), + ]; + return lines.join("\n"); +} + +export function buildGrokMessage( + messages: Array>, + toolRegistry: GrokToolRegistry, + toolChoice: unknown +): string { + const manifest = buildClientToolManifest(toolRegistry, toolChoice); + return parseOpenAIMessages(messages, manifest); +} + +export function propertyType(properties: Record, key: string): string | undefined { + const prop = properties[key]; + if (!prop || typeof prop !== "object") return undefined; + const type = (prop as Record).type; + return typeof type === "string" ? type : undefined; +} + +export function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ""; +} + +export function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + } + return undefined; +} + +export function defaultRequiredValue( + key: string, + type: string | undefined, + args: Record, + intent: string +): unknown { + const lower = key.toLowerCase(); + const command = firstString(args.command, args.cmd, args.shell, args.input); + const path = firstString(args.filePath, args.file_path, args.path, args.filename); + const query = firstString(args.query, args.search, args.input); + const url = firstString(args.url, args.uri); + + if (lower === "command" || lower === "cmd") return command; + if (lower === "filepath" || lower === "file_path" || lower === "path") return path; + if (lower === "query" || lower === "search") return query; + if (lower === "url" || lower === "uri") return url; + if (lower === "input") return query || url || command || path; + if (lower === "description" || lower === "reason" || lower === "intent_reason") { + if (command) return `Execute shell command: ${command}`; + if (path) return `Read file: ${path}`; + if (url) return `Fetch URL: ${url}`; + if (query) return `Search: ${query}`; + return `Grok Web ${intent} tool call`; + } + if (lower === "intent_data_sensitivity") return "private"; + return undefined; +} + +export function extractNumericUserParam(text: string, names: string[]): number | undefined { + const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const re = new RegExp(`\\b(?:${escaped})\\s*(?:=|:|a|de)?\\s*(\\d+)`, "i"); + const match = text.match(re); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isFinite(value) ? value : undefined; +} + +export function adaptArgumentsToDeclaredTool( + toolName: string, + args: Record, + toolRegistry: GrokToolRegistry, + intent: string, + options: { preserveUnknownArgs?: boolean } = { preserveUnknownArgs: true } +): Record { + const tool = toolRegistry.toolsByName.get(toolName); + if (!tool) return args; + const properties = getSchemaProperties(tool.parameters); + const required = getSchemaRequired(tool.parameters); + const out: Record = { ...args }; + + // Normalize common aliases only when the declared schema expects them. + if ("filePath" in properties && !hasValue(out.filePath)) + out.filePath = firstString(args.filePath, args.file_path, args.path); + if ("file_path" in properties && !hasValue(out.file_path)) + out.file_path = firstString(args.file_path, args.filePath, args.path); + if ("path" in properties && !hasValue(out.path)) + out.path = firstString(args.path, args.filePath, args.file_path); + if ("query" in properties && !hasValue(out.query)) + out.query = firstString(args.query, args.search, args.input); + if ("url" in properties && !hasValue(out.url)) out.url = firstString(args.url, args.uri); + if ("uri" in properties && !hasValue(out.uri)) out.uri = firstString(args.uri, args.url); + if ("input" in properties && !hasValue(out.input)) + out.input = firstString(args.input, args.query, args.command); + + for (const key of required) { + if (hasValue(out[key])) continue; + const value = defaultRequiredValue(key, propertyType(properties, key), out, intent); + if (value !== undefined) out[key] = value; + } + + if (options.preserveUnknownArgs !== false || Object.keys(properties).length === 0) return out; + + const filtered: Record = {}; + for (const key of Object.keys(properties)) { + if (hasValue(out[key])) filtered[key] = out[key]; + } + for (const key of required) { + if (hasValue(out[key])) filtered[key] = out[key]; + } + return filtered; +} + +export function normalizeArbitraryToolArguments(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return { input: value }; + } + } + return {}; +} + +export function parseClientToolCallMarkup( + text: string, + toolRegistry: GrokToolRegistry +): OpenAIToolCall[] | null { + if (!toolRegistry.enabled || !text.includes("")) return null; + const calls: OpenAIToolCall[] = []; + const re = /\s*([\s\S]*?)\s*<\/tool_call>/g; + for (const match of text.matchAll(re)) { + let parsed: unknown; + try { + parsed = JSON.parse(match[1]); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object") continue; + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + if (!name || !toolRegistry.toolsByName.has(name)) continue; + const rawArgs = normalizeArbitraryToolArguments(record.arguments); + const args = adaptArgumentsToDeclaredTool(name, rawArgs, toolRegistry, "clientTool", { + preserveUnknownArgs: true, + }); + if (toolRegistry.executedToolKeys.has(semanticToolKey(name, args))) continue; + calls.push({ + id: + typeof record.id === "string" && record.id.trim() + ? record.id.trim() + : `call_${crypto.randomUUID()}`, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }); + } + return calls.length > 0 ? calls : null; +} + +export function hasOpenToolCallMarkup(text: string): boolean { + return ( + /]*$/.test(text) || + (text.includes("") && !text.includes("")) + ); +} diff --git a/open-sse/executors/grok-web/types.ts b/open-sse/executors/grok-web/types.ts new file mode 100644 index 0000000000..20984f6dc3 --- /dev/null +++ b/open-sse/executors/grok-web/types.ts @@ -0,0 +1,47 @@ +// Grok NDJSON stream response/event types. Extracted verbatim from grok-web.ts. + +// ─── NDJSON stream types ──────────────────────────────────────────────────── + +export interface GrokStreamResponse { + token?: string; + isThinking?: boolean; + reasoning?: string; + reasoningContent?: string; + reasoning_content?: string; + thinking?: string; + thought?: string; + responseId?: string; + messageTag?: string; + messageStepId?: number; + toolUsageCardId?: string; + toolUsageCard?: { + toolUsageCardId?: string; + bash?: { args?: Record }; + readFile?: { args?: Record }; + read_file?: { args?: Record }; + webSearch?: { args?: Record }; + browsePage?: { args?: Record }; + browse_page?: { args?: Record }; + }; + webSearchResults?: { + results?: Array>; + }; + llmInfo?: { modelHash?: string }; + modelResponse?: { + message?: string; + reasoning?: string; + reasoningContent?: string; + reasoning_content?: string; + thinking?: string; + thought?: string; + responseId?: string; + generatedImageUrls?: string[]; + metadata?: { llm_info?: { modelHash?: string } }; + pipelineToken?: string; + }; +} + +export interface GrokStreamEvent { + result?: { response?: GrokStreamResponse }; + error?: { message?: string; code?: string }; +} diff --git a/open-sse/executors/huggingchat.ts b/open-sse/executors/huggingchat.ts index 46db72840e..7b7557ce02 100644 --- a/open-sse/executors/huggingchat.ts +++ b/open-sse/executors/huggingchat.ts @@ -27,6 +27,7 @@ import { import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; +import { streamJsonlToOpenAi, readJsonlResponse } from "./huggingchat/jsonlStream.ts"; const HUGGINGFACE_BASE = "https://huggingface.co"; const CONVERSATION_URL = `${HUGGINGFACE_BASE}/chat/conversation`; @@ -108,10 +109,6 @@ function buildConversationPrompt(messages: Array>): { }; } -function sseChunk(data: unknown): string { - return `data: ${JSON.stringify(data)}\n\n`; -} - function estimateTokens(text: string): number { return Math.max(1, Math.ceil((text || "").length / 4)); } @@ -245,222 +242,6 @@ function mergeCookieHeaderWithSetCookie(cookieHeader: string, setCookieHeaders: return [...cookieMap.entries()].map(([name, value]) => `${name}=${value}`).join("; "); } -function parseJsonlLine(line: string): { - token?: string; - done?: boolean; - error?: string; - text?: string; -} { - try { - const event = JSON.parse(line); - - if (event.type === "stream" && typeof event.token === "string") { - const token = event.token.replace(/\0/g, ""); - if (token) return { token }; - } - - if (event.type === "finalAnswer" && typeof event.text === "string") { - return { text: event.text, done: true }; - } - - if (event.type === "status") { - if (event.status === "error") { - return { error: event.message || "HuggingChat generation error" }; - } - if (event.status === "finished") { - return { done: true }; - } - } - } catch { - // Skip non-JSON lines - } - - return {}; -} - -async function* streamJsonlToOpenAi( - body: ReadableStream, - model: string, - id: string, - created: number, - signal?: AbortSignal | null -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let emittedRole = false; - let fullText = ""; - let finished = false; - - try { - while (true) { - if (signal?.aborted) break; - - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const parsed = parseJsonlLine(trimmed); - - if (parsed.error) { - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }); - yield "data: [DONE]\n\n"; - finished = true; - return; - } - - if (parsed.token) { - if (!emittedRole) { - emittedRole = true; - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }); - } - - fullText += parsed.token; - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }], - }); - } - - if (parsed.text) { - const remaining = parsed.text.slice(fullText.length); - if (remaining) { - if (!emittedRole) { - emittedRole = true; - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }); - } - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { content: remaining }, finish_reason: null }], - }); - } - finished = true; - break; - } - - if (parsed.done) { - finished = true; - break; - } - } - - if (finished) break; - } - - if (!finished && buffer.trim()) { - const parsed = parseJsonlLine(buffer.trim()); - if (parsed.token && !signal?.aborted) { - if (!emittedRole) { - emittedRole = true; - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }); - } - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }], - }); - } - } - } finally { - reader.releaseLock(); - } - - if (!signal?.aborted) { - yield sseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }); - yield "data: [DONE]\n\n"; - } -} - -async function readJsonlResponse( - body: ReadableStream, - signal?: AbortSignal | null -): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let fullText = ""; - - try { - while (true) { - if (signal?.aborted) break; - - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const parsed = parseJsonlLine(trimmed); - if (parsed.token) fullText += parsed.token; - if (parsed.text) return parsed.text; - if (parsed.error) throw new Error(parsed.error); - } - } - - if (buffer.trim()) { - const parsed = parseJsonlLine(buffer.trim()); - if (parsed.text) return parsed.text; - if (parsed.token) fullText += parsed.token; - } - } finally { - reader.releaseLock(); - } - - return fullText; -} - // -- Executor ---------------------------------------------------------------- export class HuggingChatExecutor extends BaseExecutor { diff --git a/open-sse/executors/huggingchat/jsonlStream.ts b/open-sse/executors/huggingchat/jsonlStream.ts new file mode 100644 index 0000000000..b09bcb2c30 --- /dev/null +++ b/open-sse/executors/huggingchat/jsonlStream.ts @@ -0,0 +1,221 @@ +// Pure JSONL stream translation (HuggingChat NDJSON -> OpenAI SSE). Verbatim from huggingchat.ts. + +export function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +export function parseJsonlLine(line: string): { + token?: string; + done?: boolean; + error?: string; + text?: string; +} { + try { + const event = JSON.parse(line); + + if (event.type === "stream" && typeof event.token === "string") { + const token = event.token.replace(/\0/g, ""); + if (token) return { token }; + } + + if (event.type === "finalAnswer" && typeof event.text === "string") { + return { text: event.text, done: true }; + } + + if (event.type === "status") { + if (event.status === "error") { + return { error: event.message || "HuggingChat generation error" }; + } + if (event.status === "finished") { + return { done: true }; + } + } + } catch { + // Skip non-JSON lines + } + + return {}; +} + +export async function* streamJsonlToOpenAi( + body: ReadableStream, + model: string, + id: string, + created: number, + signal?: AbortSignal | null +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let emittedRole = false; + let fullText = ""; + let finished = false; + + try { + while (true) { + if (signal?.aborted) break; + + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parsed = parseJsonlLine(trimmed); + + if (parsed.error) { + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + yield "data: [DONE]\n\n"; + finished = true; + return; + } + + if (parsed.token) { + if (!emittedRole) { + emittedRole = true; + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + } + + fullText += parsed.token; + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }], + }); + } + + if (parsed.text) { + const remaining = parsed.text.slice(fullText.length); + if (remaining) { + if (!emittedRole) { + emittedRole = true; + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + } + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: remaining }, finish_reason: null }], + }); + } + finished = true; + break; + } + + if (parsed.done) { + finished = true; + break; + } + } + + if (finished) break; + } + + if (!finished && buffer.trim()) { + const parsed = parseJsonlLine(buffer.trim()); + if (parsed.token && !signal?.aborted) { + if (!emittedRole) { + emittedRole = true; + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + } + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: parsed.token }, finish_reason: null }], + }); + } + } + } finally { + reader.releaseLock(); + } + + if (!signal?.aborted) { + yield sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + yield "data: [DONE]\n\n"; + } +} + +export async function readJsonlResponse( + body: ReadableStream, + signal?: AbortSignal | null +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fullText = ""; + + try { + while (true) { + if (signal?.aborted) break; + + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parsed = parseJsonlLine(trimmed); + if (parsed.token) fullText += parsed.token; + if (parsed.text) return parsed.text; + if (parsed.error) throw new Error(parsed.error); + } + } + + if (buffer.trim()) { + const parsed = parseJsonlLine(buffer.trim()); + if (parsed.text) return parsed.text; + if (parsed.token) fullText += parsed.token; + } + } finally { + reader.releaseLock(); + } + + return fullText; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 65d09889e3..5984fde2ee 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -28,6 +28,7 @@ import { GitlabExecutor } from "./gitlab.ts"; import { NlpCloudExecutor } from "./nlpcloud.ts"; import { WindsurfExecutor } from "./windsurf.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; +import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; import { AdaptaWebExecutor } from "./adapta-web.ts"; @@ -54,6 +55,7 @@ import { MimocodeExecutor } from "./mimocode.ts"; import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +import { XaiExecutor } from "./xai.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -154,6 +156,8 @@ const executors = { cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free + auggie: new AuggieExecutor(), + xai: new XaiExecutor(), }; const defaultCache = new Map(); @@ -199,6 +203,7 @@ export { GitlabExecutor } from "./gitlab.ts"; export { NlpCloudExecutor } from "./nlpcloud.ts"; export { WindsurfExecutor } from "./windsurf.ts"; export { DevinCliExecutor } from "./devin-cli.ts"; +export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; @@ -217,3 +222,4 @@ export { MimocodeExecutor } from "./mimocode.ts"; export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +export { XaiExecutor } from "./xai.ts"; diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index f45e9d1072..cf4ff4bdbe 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -36,7 +36,24 @@ const CHAT_URL = `${BASE_URL}/apiv2/kimi.gateway.chat.v1.ChatService/Chat`; const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; -const DEFAULT_SCENARIO = "SCENARIO_K2D5"; +/** + * Map a Kimi model id (the `key` field from `GetAvailableModels`) to the + * request shape the upstream expects. Today only the chat-tier `k2d6` family + * is supported — the agent variants (`k2d6-agent`, `k2d6-agent-ultra`) need + * a different scenario (`SCENARIO_OK_COMPUTER`) plus `kimiPlusId` / + * `agentMode` fields that this executor does not shape; users who need + * agentic Kimi should use the `kimi-coding` (api.kimi.com) provider. + */ +export interface KimiModelConfig { + scenario: string; + thinking: boolean; +} + +export function resolveModelConfig(modelId: string): KimiModelConfig { + if (modelId === "k2d6-thinking") return { scenario: "SCENARIO_K2D5", thinking: true }; + // `k2d6` (Instant) and any unknown id fall back to the default chat scenario. + return { scenario: "SCENARIO_K2D5", thinking: false }; +} /** Wrap a JSON message in the 5-byte Connect streaming envelope (flags + length). */ export function frameConnectMessage(json: string): Uint8Array { @@ -206,14 +223,14 @@ export class KimiWebExecutor extends BaseExecutor { return headers; } - private buildRequestBody(prompt: string, wantThinking: boolean): string { + private buildRequestBody(prompt: string, wantThinking: boolean, scenario: string): string { return JSON.stringify({ - scenario: DEFAULT_SCENARIO, + scenario, tools: [{ type: "TOOL_TYPE_SEARCH", search: {} }, { type: "TOOL_TYPE_CRON_JOB" }], message: { role: "user", blocks: [{ message_id: "", text: { content: prompt } }], - scenario: DEFAULT_SCENARIO, + scenario, }, options: { thinking: wantThinking, enable_plugin: true }, }); @@ -236,14 +253,13 @@ export class KimiWebExecutor extends BaseExecutor { const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; const modelId = (bodyObj.model as string) || "kimi-default"; - // Decide thinking intent. A user sending `reasoning_effort: "none"` is - // explicit — honour it even when the model id suggests a thinking variant. - // Otherwise thinking models (kimi-k2.6 etc.) default to thinking on. - const modelWantsThinking = /k2\.6|k2-6|think/i.test(modelId); - const wantThinking = bodyObj.reasoning_effort === "none" ? false : modelWantsThinking; + // Resolve scenario + default thinking flag from the model id (catalog truth), + // then honour an explicit `reasoning_effort: "none"` override from the caller. + const modelConfig = resolveModelConfig(modelId); + const wantThinking = bodyObj.reasoning_effort === "none" ? false : modelConfig.thinking; const prompt = foldMessages(messages); - const reqBody = this.buildRequestBody(prompt, wantThinking); + const reqBody = this.buildRequestBody(prompt, wantThinking, modelConfig.scenario); const reqHeaders = this.buildKimiHeaders(jwt); // Connect framing wraps the JSON body in a 5-byte envelope. Without it the diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 614efbaf3b..26c3135257 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -8,7 +8,12 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.ts"; -import { splitInlineThinking, flushPendingThinking, type KiroThinkingState } from "./kiroThinking.ts"; +import { + splitInlineThinking, + flushPendingThinking, + type KiroThinkingState, +} from "./kiroThinking.ts"; +import { ByteQueue, TEXT_ENCODER, parseEventFrame } from "./kiro/eventstream.ts"; type JsonRecord = Record; @@ -41,97 +46,6 @@ type KiroStreamState = { thinking?: KiroThinkingState; }; -type EventFrame = { - headers: Record; - payload: JsonRecord | null; -}; - -class ByteQueue { - private chunks: Uint8Array[] = []; - private headOffset = 0; - length = 0; - - push(chunk: Uint8Array) { - if (!(chunk instanceof Uint8Array) || chunk.length === 0) return; - this.chunks.push(chunk); - this.length += chunk.length; - } - - peekUint32BE(offset = 0): number | null { - if (this.length < offset + 4) return null; - - let value = 0; - for (let i = 0; i < 4; i++) { - value = (value << 8) | this.byteAt(offset + i); - } - return value >>> 0; - } - - read(length: number): Uint8Array | null { - if (length < 0 || this.length < length) return null; - - const output = new Uint8Array(length); - let written = 0; - - while (written < length) { - const head = this.chunks[0]; - const available = head.length - this.headOffset; - const take = Math.min(available, length - written); - output.set(head.subarray(this.headOffset, this.headOffset + take), written); - written += take; - this.headOffset += take; - this.length -= take; - - if (this.headOffset >= head.length) { - this.chunks.shift(); - this.headOffset = 0; - } - } - - return output; - } - - private byteAt(offset: number): number { - let remaining = offset; - for (let i = 0; i < this.chunks.length; i++) { - const chunk = this.chunks[i]; - const start = i === 0 ? this.headOffset : 0; - const available = chunk.length - start; - if (remaining < available) { - return chunk[start + remaining]; - } - remaining -= available; - } - return 0; - } -} - -// ── CRC32 lookup table (IEEE polynomial, no dependency) ── -const CRC32_TABLE = new Uint32Array(256); -const TEXT_ENCODER = new TextEncoder(); -const TEXT_DECODER = new TextDecoder(); -for (let i = 0; i < 256; i++) { - let c = i; - for (let j = 0; j < 8; j++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - CRC32_TABLE[i] = c >>> 0; -} - -// Full per-frame message-CRC validation is O(frame bytes) and runs for EVERY frame of -// every Kiro response on the main thread. The transport is TLS-protected and the 8-byte -// prelude CRC already guards framing, so the full-message CRC is redundant overhead that -// contributes to the CPU-runaway on large/long generations. Keep it opt-in for debugging. -const KIRO_VERIFY_FULL_CRC = process.env.KIRO_VERIFY_FULL_CRC === "true"; - -function crc32(buf: Uint8Array) { - let crc = 0xffffffff; - for (let i = 0; i < buf.length; i++) { - crc = CRC32_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -} - /** * Flush buffered tool arguments at finish boundaries. * @@ -354,16 +268,18 @@ export class KiroExecutor extends BaseExecutor { // channel. const tb = transformedBody as Record; const userContent = - ( + (( ( - ( - (tb?.conversationState as Record) - ?.currentMessage as Record - )?.userInputMessage as Record - )?.content as string - ) || ""; + (tb?.conversationState as Record)?.currentMessage as Record< + string, + unknown + > + )?.userInputMessage as Record + )?.content as string) || ""; const thinkingExpected = userContent.includes("enabled"); - const transformedResponse = this.transformEventStreamToSSE(response, model, { thinkingExpected }); + const transformedResponse = this.transformEventStreamToSSE(response, model, { + thinkingExpected, + }); return { response: transformedResponse, url, headers, transformedBody }; } @@ -494,7 +410,10 @@ export class KiroExecutor extends BaseExecutor { choices: [ { index: 0, - delta: chunkIndex === 0 ? { role: "assistant", content: text } : { content: text }, + delta: + chunkIndex === 0 + ? { role: "assistant", content: text } + : { content: text }, finish_reason: null, }, ], @@ -843,101 +762,4 @@ export class KiroExecutor extends BaseExecutor { } } -/** - * Parse AWS EventStream frame - */ -function parseEventFrame(data: Uint8Array): EventFrame | null { - try { - const view = new DataView(data.buffer, data.byteOffset); - const totalLength = view.getUint32(0, false); - const headersLength = view.getUint32(4, false); - - // ── CRC32 validation ── - // Prelude CRC covers bytes [0..7] (totalLength + headersLength) - const preludeCRC = view.getUint32(8, false); - const computedPreludeCRC = crc32(data.slice(0, 8)); - if (preludeCRC !== computedPreludeCRC) { - console.warn( - `[Kiro] Prelude CRC mismatch: expected ${preludeCRC}, got ${computedPreludeCRC} — skipping corrupted frame` - ); - return null; - } - - // Message CRC covers bytes [0..totalLength-5] (everything except the CRC itself). - // Skipped by default (O(frame bytes) per frame) — the prelude CRC above already - // validates framing and the stream is TLS-protected. Enable KIRO_VERIFY_FULL_CRC=true - // to restore full validation for debugging corrupted-stream issues. - if (KIRO_VERIFY_FULL_CRC) { - const messageCRC = view.getUint32(data.length - 4, false); - const computedMessageCRC = crc32(data.slice(0, data.length - 4)); - if (messageCRC !== computedMessageCRC) { - console.warn( - `[Kiro] Message CRC mismatch: expected ${messageCRC}, got ${computedMessageCRC} — skipping corrupted frame` - ); - return null; - } - } - // Parse headers - const headers: Record = {}; - let offset = 12; // After prelude - const headerEnd = 12 + headersLength; - - while (offset < headerEnd && offset < data.length) { - const nameLen = data[offset]; - offset++; - if (offset + nameLen > data.length) break; - - const name = TEXT_DECODER.decode(data.subarray(offset, offset + nameLen)); - offset += nameLen; - - const headerType = data[offset]; - offset++; - - if (headerType === 7) { - // String type - const valueLen = (data[offset] << 8) | data[offset + 1]; - offset += 2; - if (offset + valueLen > data.length) break; - - const value = TEXT_DECODER.decode(data.subarray(offset, offset + valueLen)); - offset += valueLen; - headers[name] = value; - } else { - break; - } - } - - // Parse payload - const payloadStart = 12 + headersLength; - const payloadEnd = data.length - 4; // Exclude message CRC - - let payload: JsonRecord | null = null; - if (payloadEnd > payloadStart) { - const payloadStr = TEXT_DECODER.decode(data.subarray(payloadStart, payloadEnd)); - - // Skip empty or whitespace-only payloads - if (!payloadStr || !payloadStr.trim()) { - return { headers, payload: null }; - } - - try { - payload = JSON.parse(payloadStr); - } catch (parseError) { - const err = parseError instanceof Error ? parseError : new Error(String(parseError)); - // Log parse error for debugging - console.warn( - `[Kiro] Failed to parse payload: ${err.message} | payload: ${payloadStr.substring(0, 100)}` - ); - payload = { raw: payloadStr }; - } - } - - return { headers, payload }; - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - console.warn(`[Kiro] Frame parse error: ${error.message}`); - return null; - } -} - export default KiroExecutor; diff --git a/open-sse/executors/kiro/eventstream.ts b/open-sse/executors/kiro/eventstream.ts new file mode 100644 index 0000000000..39bb7e02cb --- /dev/null +++ b/open-sse/executors/kiro/eventstream.ts @@ -0,0 +1,192 @@ +// Pure AWS EventStream binary framing for Kiro (ByteQueue, CRC32, frame parsing). +// Extracted verbatim from kiro.ts. Self-contained (local JsonRecord, no host imports). + +type JsonRecord = Record; + +export type EventFrame = { + headers: Record; + payload: JsonRecord | null; +}; + +export class ByteQueue { + private chunks: Uint8Array[] = []; + private headOffset = 0; + length = 0; + + push(chunk: Uint8Array) { + if (!(chunk instanceof Uint8Array) || chunk.length === 0) return; + this.chunks.push(chunk); + this.length += chunk.length; + } + + peekUint32BE(offset = 0): number | null { + if (this.length < offset + 4) return null; + + let value = 0; + for (let i = 0; i < 4; i++) { + value = (value << 8) | this.byteAt(offset + i); + } + return value >>> 0; + } + + read(length: number): Uint8Array | null { + if (length < 0 || this.length < length) return null; + + const output = new Uint8Array(length); + let written = 0; + + while (written < length) { + const head = this.chunks[0]; + const available = head.length - this.headOffset; + const take = Math.min(available, length - written); + output.set(head.subarray(this.headOffset, this.headOffset + take), written); + written += take; + this.headOffset += take; + this.length -= take; + + if (this.headOffset >= head.length) { + this.chunks.shift(); + this.headOffset = 0; + } + } + + return output; + } + + private byteAt(offset: number): number { + let remaining = offset; + for (let i = 0; i < this.chunks.length; i++) { + const chunk = this.chunks[i]; + const start = i === 0 ? this.headOffset : 0; + const available = chunk.length - start; + if (remaining < available) { + return chunk[start + remaining]; + } + remaining -= available; + } + return 0; + } +} + +// ── CRC32 lookup table (IEEE polynomial, no dependency) ── +export const CRC32_TABLE = new Uint32Array(256); +export const TEXT_ENCODER = new TextEncoder(); +export const TEXT_DECODER = new TextDecoder(); +for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + CRC32_TABLE[i] = c >>> 0; +} + +// Full per-frame message-CRC validation is O(frame bytes) and runs for EVERY frame of +// every Kiro response on the main thread. The transport is TLS-protected and the 8-byte +// prelude CRC already guards framing, so the full-message CRC is redundant overhead that +// contributes to the CPU-runaway on large/long generations. Keep it opt-in for debugging. +export const KIRO_VERIFY_FULL_CRC = process.env.KIRO_VERIFY_FULL_CRC === "true"; + +export function crc32(buf: Uint8Array) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = CRC32_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Parse AWS EventStream frame + */ +export function parseEventFrame(data: Uint8Array): EventFrame | null { + try { + const view = new DataView(data.buffer, data.byteOffset); + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + + // ── CRC32 validation ── + // Prelude CRC covers bytes [0..7] (totalLength + headersLength) + const preludeCRC = view.getUint32(8, false); + const computedPreludeCRC = crc32(data.slice(0, 8)); + if (preludeCRC !== computedPreludeCRC) { + console.warn( + `[Kiro] Prelude CRC mismatch: expected ${preludeCRC}, got ${computedPreludeCRC} — skipping corrupted frame` + ); + return null; + } + + // Message CRC covers bytes [0..totalLength-5] (everything except the CRC itself). + // Skipped by default (O(frame bytes) per frame) — the prelude CRC above already + // validates framing and the stream is TLS-protected. Enable KIRO_VERIFY_FULL_CRC=true + // to restore full validation for debugging corrupted-stream issues. + if (KIRO_VERIFY_FULL_CRC) { + const messageCRC = view.getUint32(data.length - 4, false); + const computedMessageCRC = crc32(data.slice(0, data.length - 4)); + if (messageCRC !== computedMessageCRC) { + console.warn( + `[Kiro] Message CRC mismatch: expected ${messageCRC}, got ${computedMessageCRC} — skipping corrupted frame` + ); + return null; + } + } + // Parse headers + const headers: Record = {}; + let offset = 12; // After prelude + const headerEnd = 12 + headersLength; + + while (offset < headerEnd && offset < data.length) { + const nameLen = data[offset]; + offset++; + if (offset + nameLen > data.length) break; + + const name = TEXT_DECODER.decode(data.subarray(offset, offset + nameLen)); + offset += nameLen; + + const headerType = data[offset]; + offset++; + + if (headerType === 7) { + // String type + const valueLen = (data[offset] << 8) | data[offset + 1]; + offset += 2; + if (offset + valueLen > data.length) break; + + const value = TEXT_DECODER.decode(data.subarray(offset, offset + valueLen)); + offset += valueLen; + headers[name] = value; + } else { + break; + } + } + + // Parse payload + const payloadStart = 12 + headersLength; + const payloadEnd = data.length - 4; // Exclude message CRC + + let payload: JsonRecord | null = null; + if (payloadEnd > payloadStart) { + const payloadStr = TEXT_DECODER.decode(data.subarray(payloadStart, payloadEnd)); + + // Skip empty or whitespace-only payloads + if (!payloadStr || !payloadStr.trim()) { + return { headers, payload: null }; + } + + try { + payload = JSON.parse(payloadStr); + } catch (parseError) { + const err = parseError instanceof Error ? parseError : new Error(String(parseError)); + // Log parse error for debugging + console.warn( + `[Kiro] Failed to parse payload: ${err.message} | payload: ${payloadStr.substring(0, 100)}` + ); + payload = { raw: payloadStr }; + } + } + + return { headers, payload }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + console.warn(`[Kiro] Frame parse error: ${error.message}`); + return null; + } +} diff --git a/open-sse/executors/muse-spark-web.ts b/open-sse/executors/muse-spark-web.ts index 6a7cac1f1d..106c28fb9d 100644 --- a/open-sse/executors/muse-spark-web.ts +++ b/open-sse/executors/muse-spark-web.ts @@ -13,6 +13,11 @@ import { normalizeSessionCookieHeader, normalizeSessionCookieHeaders, } from "@/lib/providers/webCookieAuth"; +import { + type ParsedMetaAiResponse, + isRecord, + parseMetaAiResponseText, +} from "./muse-spark-web/response-parser.ts"; const META_AI_GRAPHQL_API = "https://www.meta.ai/api/graphql"; // Meta rebranded the chat product from "Abra" to "Ecto"; the session cookie @@ -48,25 +53,6 @@ const MODEL_MAP: Record = { "muse-spark-contemplating": { mode: "think_hard", isThinking: true }, }; -type MetaSseFrame = { - event: string; - data: string; -}; - -type ParsedMetaAiResponse = { - content: string; - deltas: string[]; - reasoningContent: string; - reasoningDeltas: string[]; - errorCode: string | null; - errorMessage: string | null; - status: number; -}; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function extractMessageText(content: unknown): string { if (typeof content === "string") { return content.trim(); @@ -387,365 +373,6 @@ function buildMetaAiRequestBody(prompt: string, model: string, conversation: Con }; } -function parseMetaSseFrames(text: string): MetaSseFrame[] { - const frames: MetaSseFrame[] = []; - const lines = text.split(/\r?\n/); - let currentEvent = "message"; - let dataLines: string[] = []; - - const flush = () => { - if (dataLines.length === 0 && currentEvent === "message") { - return; - } - - frames.push({ - event: currentEvent, - data: dataLines.join("\n").trim(), - }); - - currentEvent = "message"; - dataLines = []; - }; - - for (const line of lines) { - if (!line) { - flush(); - continue; - } - - if (line.startsWith(":")) { - continue; - } - - if (line.startsWith("event:")) { - currentEvent = line.slice("event:".length).trim() || "message"; - continue; - } - - if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - } - - flush(); - return frames; -} - -function readMetaJsonPayloads(text: string): Array> { - const trimmed = text.trim(); - if (trimmed.startsWith("{")) { - try { - const parsed = JSON.parse(trimmed); - return isRecord(parsed) ? [parsed] : []; - } catch { - return []; - } - } - - return parseMetaSseFrames(text) - .filter((frame) => frame.data) - .map((frame) => { - try { - const parsed = JSON.parse(frame.data); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } - }) - .filter((frame): frame is Record => !!frame); -} - -const META_AI_REASONING_KEYS = [ - "reasoning", - "reasoningContent", - "reasoning_content", - "reasoningText", - "thinking", - "thinkingContent", - "thinkingText", - "thought", - "thoughtText", - "thoughts", - "internalThoughts", - "chainOfThought", - "thinkingTrace", - "thinking_trace", -] as const; - -const META_AI_NESTED_RENDERER_KEYS = [ - "contentRenderer", - "textContent", - "message", - "mediaContent", - "unified_response", - "unifiedResponseContent", - "sections", - "view_model", - "primitive", - "primitives", - "nested_responses", -] as const; - -function collectRendererTexts(value: unknown, seen: Set, depth = 0): string[] { - if (depth > 8) { - return []; - } - - if (typeof value === "string") { - const normalized = value.trim(); - if (!normalized || seen.has(normalized)) { - return []; - } - seen.add(normalized); - return [normalized]; - } - - if (Array.isArray(value)) { - return value.flatMap((item) => collectRendererTexts(item, seen, depth + 1)); - } - - if (!isRecord(value)) { - return []; - } - - const parts: string[] = []; - if (typeof value.text === "string") { - parts.push(...collectRendererTexts(value.text, seen, depth + 1)); - } - - for (const key of [ - "contentRenderer", - "textContent", - "message", - "mediaContent", - "unified_response", - "unifiedResponseContent", - "sections", - "view_model", - "primitive", - "primitives", - "nested_responses", - ]) { - if (key in value) { - parts.push(...collectRendererTexts(value[key], seen, depth + 1)); - } - } - - return parts; -} - -function collectReasoningTexts( - value: unknown, - seen: Set, - depth = 0, - force = false -): string[] { - if (depth > 8) { - return []; - } - - if (typeof value === "string") { - const normalized = value.trim(); - if (!force || !normalized || seen.has(normalized)) { - return []; - } - seen.add(normalized); - return [normalized]; - } - - if (Array.isArray(value)) { - return value.flatMap((item) => collectReasoningTexts(item, seen, depth + 1, force)); - } - - if (!isRecord(value)) { - return []; - } - - const typename = typeof value.__typename === "string" ? value.__typename : ""; - const localForce = force || /reasoning|thinking|thought/i.test(typename); - const parts: string[] = []; - - if (typeof value.text === "string" && localForce) { - parts.push(...collectReasoningTexts(value.text, seen, depth + 1, true)); - } - - for (const key of META_AI_REASONING_KEYS) { - if (key in value) { - parts.push(...collectReasoningTexts(value[key], seen, depth + 1, true)); - } - } - - for (const key of META_AI_NESTED_RENDERER_KEYS) { - if (key in value) { - parts.push(...collectReasoningTexts(value[key], seen, depth + 1, localForce)); - } - } - - return parts; -} - -function extractAssistantContent(message: Record): string { - if (typeof message.content === "string" && message.content.length > 0) { - return message.content; - } - - const contentRenderer = isRecord(message.contentRenderer) ? message.contentRenderer : null; - if (!contentRenderer) { - return ""; - } - - const parts = collectRendererTexts(contentRenderer, new Set()); - return parts.join("\n\n").trim(); -} - -function extractAssistantReasoning(message: Record): string { - const parts = collectReasoningTexts(message, new Set()); - return parts.join("\n\n").trim(); -} - -function extractAssistantError(message: Record) { - const error = isRecord(message.error) ? message.error : null; - const streamingState = - typeof message.streamingState === "string" ? message.streamingState.toUpperCase() : null; - return { - code: typeof error?.code === "string" ? error.code : null, - message: - typeof error?.message === "string" - ? error.message.trim() - : streamingState === "ERROR" && - typeof message.content === "string" && - message.content.trim() - ? message.content.trim() - : null, - }; -} - -function classifyMetaAiError(errorMessage: string | null, content: string) { - const combined = `${errorMessage || ""}\n${content}`.trim(); - if (!combined) { - return null; - } - - if (/authentication required to send messages|login is required|sign in/i.test(combined)) { - return { - status: 401, - message: "Meta AI auth failed — your meta.ai ecto_1_sess cookie may be missing or expired.", - }; - } - - if (/limit exceeded|rate limit|too many requests/i.test(combined)) { - return { - status: 429, - message: "Meta AI rate limited the session. Wait a moment and retry.", - }; - } - - if (/blocked by our security system|security system/i.test(combined)) { - return { - status: 403, - message: - "Meta AI blocked the request through its web security checks. Refresh the session cookie and retry.", - }; - } - - return null; -} - -function parseMetaAiResponseText(text: string, isThinkingModel: boolean): ParsedMetaAiResponse { - let lastContent = ""; - const deltas: string[] = []; - let lastReasoning = ""; - const reasoningDeltas: string[] = []; - let errorCode: string | null = null; - let errorMessage: string | null = null; - - for (const payload of readMetaJsonPayloads(text)) { - if (Array.isArray(payload.errors) && payload.errors.length > 0) { - const firstError = payload.errors.find( - (item) => isRecord(item) && typeof item.message === "string" - ); - if (isRecord(firstError) && typeof firstError.message === "string") { - errorMessage = firstError.message.trim(); - } - } - - const data = isRecord(payload.data) ? payload.data : null; - const sendMessageStream = isRecord(data?.sendMessageStream) ? data?.sendMessageStream : null; - if (!sendMessageStream || sendMessageStream.__typename !== "AssistantMessage") { - continue; - } - - const content = extractAssistantContent(sendMessageStream); - if (content && content !== lastContent) { - deltas.push(content.startsWith(lastContent) ? content.slice(lastContent.length) : content); - lastContent = content; - } - - if (isThinkingModel) { - const reasoning = extractAssistantReasoning(sendMessageStream); - if (reasoning && reasoning !== content && reasoning !== lastReasoning) { - reasoningDeltas.push( - reasoning.startsWith(lastReasoning) ? reasoning.slice(lastReasoning.length) : reasoning - ); - lastReasoning = reasoning; - } - } - - const upstreamError = extractAssistantError(sendMessageStream); - if (upstreamError.message) { - errorMessage = upstreamError.message; - errorCode = upstreamError.code; - } - } - - const classifiedError = classifyMetaAiError(errorMessage, lastContent); - if (classifiedError) { - return { - content: lastContent, - deltas, - reasoningContent: lastReasoning, - reasoningDeltas, - errorCode, - errorMessage: classifiedError.message, - status: classifiedError.status, - }; - } - - if (errorMessage) { - return { - content: lastContent, - deltas, - reasoningContent: lastReasoning, - reasoningDeltas, - errorCode, - errorMessage: `Meta AI returned an error: ${errorMessage}`, - status: 502, - }; - } - - if (!lastContent) { - return { - content: "", - deltas: [], - reasoningContent: lastReasoning, - reasoningDeltas, - errorCode: null, - errorMessage: "Meta AI returned no assistant content", - status: 502, - }; - } - - return { - content: lastContent, - deltas: deltas.filter((delta) => delta.length > 0), - reasoningContent: lastReasoning, - reasoningDeltas: reasoningDeltas.filter((delta) => delta.length > 0), - errorCode: null, - errorMessage: null, - status: 200, - }; -} - function sseChunk(data: unknown): string { return `data: ${JSON.stringify(data)}\n\n`; } diff --git a/open-sse/executors/muse-spark-web/response-parser.ts b/open-sse/executors/muse-spark-web/response-parser.ts new file mode 100644 index 0000000000..2e80a10447 --- /dev/null +++ b/open-sse/executors/muse-spark-web/response-parser.ts @@ -0,0 +1,383 @@ +// Pure Meta AI SSE/JSON response parsing + content/reasoning/error extraction. +// Extracted verbatim from muse-spark-web.ts. No host state/fetch/auth. + +export type MetaSseFrame = { + event: string; + data: string; +}; + +export type ParsedMetaAiResponse = { + content: string; + deltas: string[]; + reasoningContent: string; + reasoningDeltas: string[]; + errorCode: string | null; + errorMessage: string | null; + status: number; +}; + +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function parseMetaSseFrames(text: string): MetaSseFrame[] { + const frames: MetaSseFrame[] = []; + const lines = text.split(/\r?\n/); + let currentEvent = "message"; + let dataLines: string[] = []; + + const flush = () => { + if (dataLines.length === 0 && currentEvent === "message") { + return; + } + + frames.push({ + event: currentEvent, + data: dataLines.join("\n").trim(), + }); + + currentEvent = "message"; + dataLines = []; + }; + + for (const line of lines) { + if (!line) { + flush(); + continue; + } + + if (line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + currentEvent = line.slice("event:".length).trim() || "message"; + continue; + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + + flush(); + return frames; +} + +export function readMetaJsonPayloads(text: string): Array> { + const trimmed = text.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + return isRecord(parsed) ? [parsed] : []; + } catch { + return []; + } + } + + return parseMetaSseFrames(text) + .filter((frame) => frame.data) + .map((frame) => { + try { + const parsed = JSON.parse(frame.data); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + }) + .filter((frame): frame is Record => !!frame); +} + +export const META_AI_REASONING_KEYS = [ + "reasoning", + "reasoningContent", + "reasoning_content", + "reasoningText", + "thinking", + "thinkingContent", + "thinkingText", + "thought", + "thoughtText", + "thoughts", + "internalThoughts", + "chainOfThought", + "thinkingTrace", + "thinking_trace", +] as const; + +export const META_AI_NESTED_RENDERER_KEYS = [ + "contentRenderer", + "textContent", + "message", + "mediaContent", + "unified_response", + "unifiedResponseContent", + "sections", + "view_model", + "primitive", + "primitives", + "nested_responses", +] as const; + +export function collectRendererTexts(value: unknown, seen: Set, depth = 0): string[] { + if (depth > 8) { + return []; + } + + if (typeof value === "string") { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + return []; + } + seen.add(normalized); + return [normalized]; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => collectRendererTexts(item, seen, depth + 1)); + } + + if (!isRecord(value)) { + return []; + } + + const parts: string[] = []; + if (typeof value.text === "string") { + parts.push(...collectRendererTexts(value.text, seen, depth + 1)); + } + + for (const key of [ + "contentRenderer", + "textContent", + "message", + "mediaContent", + "unified_response", + "unifiedResponseContent", + "sections", + "view_model", + "primitive", + "primitives", + "nested_responses", + ]) { + if (key in value) { + parts.push(...collectRendererTexts(value[key], seen, depth + 1)); + } + } + + return parts; +} + +export function collectReasoningTexts( + value: unknown, + seen: Set, + depth = 0, + force = false +): string[] { + if (depth > 8) { + return []; + } + + if (typeof value === "string") { + const normalized = value.trim(); + if (!force || !normalized || seen.has(normalized)) { + return []; + } + seen.add(normalized); + return [normalized]; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => collectReasoningTexts(item, seen, depth + 1, force)); + } + + if (!isRecord(value)) { + return []; + } + + const typename = typeof value.__typename === "string" ? value.__typename : ""; + const localForce = force || /reasoning|thinking|thought/i.test(typename); + const parts: string[] = []; + + if (typeof value.text === "string" && localForce) { + parts.push(...collectReasoningTexts(value.text, seen, depth + 1, true)); + } + + for (const key of META_AI_REASONING_KEYS) { + if (key in value) { + parts.push(...collectReasoningTexts(value[key], seen, depth + 1, true)); + } + } + + for (const key of META_AI_NESTED_RENDERER_KEYS) { + if (key in value) { + parts.push(...collectReasoningTexts(value[key], seen, depth + 1, localForce)); + } + } + + return parts; +} + +export function extractAssistantContent(message: Record): string { + if (typeof message.content === "string" && message.content.length > 0) { + return message.content; + } + + const contentRenderer = isRecord(message.contentRenderer) ? message.contentRenderer : null; + if (!contentRenderer) { + return ""; + } + + const parts = collectRendererTexts(contentRenderer, new Set()); + return parts.join("\n\n").trim(); +} + +export function extractAssistantReasoning(message: Record): string { + const parts = collectReasoningTexts(message, new Set()); + return parts.join("\n\n").trim(); +} + +export function extractAssistantError(message: Record) { + const error = isRecord(message.error) ? message.error : null; + const streamingState = + typeof message.streamingState === "string" ? message.streamingState.toUpperCase() : null; + return { + code: typeof error?.code === "string" ? error.code : null, + message: + typeof error?.message === "string" + ? error.message.trim() + : streamingState === "ERROR" && + typeof message.content === "string" && + message.content.trim() + ? message.content.trim() + : null, + }; +} + +export function classifyMetaAiError(errorMessage: string | null, content: string) { + const combined = `${errorMessage || ""}\n${content}`.trim(); + if (!combined) { + return null; + } + + if (/authentication required to send messages|login is required|sign in/i.test(combined)) { + return { + status: 401, + message: "Meta AI auth failed — your meta.ai ecto_1_sess cookie may be missing or expired.", + }; + } + + if (/limit exceeded|rate limit|too many requests/i.test(combined)) { + return { + status: 429, + message: "Meta AI rate limited the session. Wait a moment and retry.", + }; + } + + if (/blocked by our security system|security system/i.test(combined)) { + return { + status: 403, + message: + "Meta AI blocked the request through its web security checks. Refresh the session cookie and retry.", + }; + } + + return null; +} + +export function parseMetaAiResponseText( + text: string, + isThinkingModel: boolean +): ParsedMetaAiResponse { + let lastContent = ""; + const deltas: string[] = []; + let lastReasoning = ""; + const reasoningDeltas: string[] = []; + let errorCode: string | null = null; + let errorMessage: string | null = null; + + for (const payload of readMetaJsonPayloads(text)) { + if (Array.isArray(payload.errors) && payload.errors.length > 0) { + const firstError = payload.errors.find( + (item) => isRecord(item) && typeof item.message === "string" + ); + if (isRecord(firstError) && typeof firstError.message === "string") { + errorMessage = firstError.message.trim(); + } + } + + const data = isRecord(payload.data) ? payload.data : null; + const sendMessageStream = isRecord(data?.sendMessageStream) ? data?.sendMessageStream : null; + if (!sendMessageStream || sendMessageStream.__typename !== "AssistantMessage") { + continue; + } + + const content = extractAssistantContent(sendMessageStream); + if (content && content !== lastContent) { + deltas.push(content.startsWith(lastContent) ? content.slice(lastContent.length) : content); + lastContent = content; + } + + if (isThinkingModel) { + const reasoning = extractAssistantReasoning(sendMessageStream); + if (reasoning && reasoning !== content && reasoning !== lastReasoning) { + reasoningDeltas.push( + reasoning.startsWith(lastReasoning) ? reasoning.slice(lastReasoning.length) : reasoning + ); + lastReasoning = reasoning; + } + } + + const upstreamError = extractAssistantError(sendMessageStream); + if (upstreamError.message) { + errorMessage = upstreamError.message; + errorCode = upstreamError.code; + } + } + + const classifiedError = classifyMetaAiError(errorMessage, lastContent); + if (classifiedError) { + return { + content: lastContent, + deltas, + reasoningContent: lastReasoning, + reasoningDeltas, + errorCode, + errorMessage: classifiedError.message, + status: classifiedError.status, + }; + } + + if (errorMessage) { + return { + content: lastContent, + deltas, + reasoningContent: lastReasoning, + reasoningDeltas, + errorCode, + errorMessage: `Meta AI returned an error: ${errorMessage}`, + status: 502, + }; + } + + if (!lastContent) { + return { + content: "", + deltas: [], + reasoningContent: lastReasoning, + reasoningDeltas, + errorCode: null, + errorMessage: "Meta AI returned no assistant content", + status: 502, + }; + } + + return { + content: lastContent, + deltas: deltas.filter((delta) => delta.length > 0), + reasoningContent: lastReasoning, + reasoningDeltas: reasoningDeltas.filter((delta) => delta.length > 0), + errorCode: null, + errorMessage: null, + status: 200, + }; +} diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 9b4397fae4..d5c693cff3 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -13,75 +13,21 @@ import { TlsClientUnavailableError, type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; -import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { prepareToolMessages } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; - -const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; -// Perplexity's current request schema version (sent in params.version). Perplexity rejects -// stale versions with HTTP 400 — keep this in lockstep with the website's payload. -const PPLX_API_VERSION = "2.18"; -// Block use-cases the current web client advertises. The schematized API (use_schematized_api) -// validates the request shape, so this must be present (mirrors the browser request body). -const PPLX_SUPPORTED_BLOCK_USE_CASES = [ - "answer_modes", - "media_items", - "knowledge_cards", - "inline_entity_cards", - "place_widgets", - "finance_widgets", - "sports_widgets", - "news_widgets", - "shopping_widgets", - "jobs_widgets", - "search_result_widgets", - "inline_images", - "inline_assets", - "placeholder_cards", - "diff_blocks", - "inline_knowledge_cards", - "entity_group_v2", - "refinement_filters", - "canvas_mode", - "maps_preview", - "answer_tabs", - "price_comparison_widgets", - "preserve_latex", - "generic_onboarding_widgets", - "in_context_suggestions", - "pending_followups", - "inline_claims", - "unified_assets", - "workflow_steps", - "background_agents", -]; -// Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient. -// A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459). -const PPLX_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; - -const MODEL_MAP: Record = { - "pplx-auto": ["concise", "pplx_pro"], - "pplx-sonar": ["copilot", "experimental"], - "pplx-gpt": ["copilot", "gpt54"], - "pplx-gemini": ["copilot", "gemini31pro_high"], - "pplx-sonnet": ["copilot", "claude46sonnet"], - "pplx-opus": ["copilot", "claude46opus"], - "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], -}; - -const THINKING_MAP: Record = { - "pplx-gpt": "gpt54_thinking", - "pplx-sonnet": "claude46sonnetthinking", - "pplx-opus": "claude46opusthinking", -}; - -const CITATION_RE = /\[\d+\]/g; -const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; -const GROK_SELF_RE = /]*\/>/g; -const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; -const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; -const MULTI_SPACE = / {2,}/g; -const MULTI_NL = /\n{3,}/g; +import { + PPLX_SSE_ENDPOINT, + PPLX_USER_AGENT, + MODEL_MAP, + THINKING_MAP, + cleanResponse, + parseOpenAIMessages, + buildPplxRequestBody, + buildQuery, + extractContent, + sseChunk, +} from "./perplexity-web/protocol.ts"; // ─── Session continuity ───────────────────────────────────────────────────── @@ -144,434 +90,6 @@ function sessionStore( } } -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function cleanResponse(text: string, strip = true): string { - let t = text; - t = t.replace(XML_DECL_RE, ""); - t = t.replace(CITATION_RE, ""); - t = t.replace(GROK_TAG_RE, ""); - t = t.replace(GROK_SELF_RE, ""); - t = t.replace(RESPONSE_TAG_RE, ""); - if (strip) { - t = t.replace(MULTI_SPACE, " "); - t = t.replace(MULTI_NL, "\n\n"); - t = t.trim(); - } - return t; -} - -// ─── SSE types ────────────────────────────────────────────────────────────── - -interface PplxDiffPatch { - op?: string; - path?: string; - value?: unknown; -} - -interface PplxBlock { - intended_usage?: string; - markdown_block?: { - answer?: string; - chunks?: string[]; - progress?: string; - chunk_starting_offset?: number; - }; - // Schematized API (use_schematized_api) streams block updates as RFC-6902 - // JSON-patch diffs against a target field (e.g. markdown_block) instead of - // sending the whole block each frame. `field` names the block being patched. - diff_block?: { - field?: string; - patches?: PplxDiffPatch[]; - }; - web_result_block?: { - web_results?: Array<{ url?: string; name?: string; snippet?: string }>; - }; - plan_block?: { - steps?: Array<{ - step_type?: string; - search_web_content?: { queries?: Array<{ query?: string }> }; - read_results_content?: { urls?: string[] }; - }>; - goals?: Array<{ description?: string }>; - }; -} - -interface PplxStreamEvent { - status?: string; - final?: boolean; - text?: string; - blocks?: PplxBlock[]; - backend_uuid?: string; - web_results?: Array<{ url?: string; name?: string }>; - error_code?: string; - error_message?: string; - display_model?: string; -} - -// ─── SSE parsing ──────────────────────────────────────────────────────────── - -async function* readPplxSseEvents( - body: ReadableStream, - signal?: AbortSignal | null -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let dataLines: string[] = []; - - function flush(): PplxStreamEvent | null | "done" { - if (dataLines.length === 0) return null; - const payload = dataLines.join("\n"); - dataLines = []; - const trimmed = payload.trim(); - if (!trimmed || trimmed === "[DONE]") return "done"; - try { - return JSON.parse(trimmed) as PplxStreamEvent; - } catch { - return null; - } - } - - try { - while (true) { - if (signal?.aborted) return; - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - while (true) { - const idx = buffer.indexOf("\n"); - if (idx < 0) break; - const rawLine = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - - if (line === "") { - const parsed = flush(); - if (parsed === "done") return; - if (parsed) yield parsed; - continue; - } - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); - } - if (line === "event: end_of_stream") { - return; - } - } - } - - buffer += decoder.decode(); - if (buffer.trim().startsWith("data:")) { - dataLines.push(buffer.trim().slice(5).trimStart()); - } - const tail = flush(); - if (tail && tail !== "done") yield tail; - } finally { - reader.releaseLock(); - } -} - -// ─── OpenAI → Perplexity translation ──────────────────────────────────────── - -interface ParsedMessages { - systemMsg: string; - history: Array<{ role: string; content: string }>; - currentMsg: string; -} - -function parseOpenAIMessages(messages: Array>): ParsedMessages { - let systemMsg = ""; - const history: Array<{ role: string; content: string }> = []; - - for (const msg of messages) { - let role = String(msg.role || "user"); - if (role === "developer") role = "system"; - - let content = ""; - if (typeof msg.content === "string") { - content = msg.content; - } else if (Array.isArray(msg.content)) { - content = (msg.content as Array>) - .filter((c) => c.type === "text") - .map((c) => String(c.text || "")) - .join(" "); - } - if (!content.trim()) continue; - - if (role === "system") { - systemMsg += content + "\n"; - } else if (role === "user" || role === "assistant") { - history.push({ role, content }); - } - } - - let currentMsg = ""; - if (history.length > 0 && history[history.length - 1].role === "user") { - currentMsg = history.pop()!.content; - } - - return { systemMsg, history, currentMsg }; -} - -function buildPplxRequestBody( - query: string, - dslQuery: string, - mode: string, - modelPref: string, - followUpUuid: string | null, - requestId: string -): Record { - const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; - - // Mirrors the current www.perplexity.ai/rest/sse/perplexity_ask request body. Perplexity's - // schematized API validates this shape; an outdated version or missing required fields → HTTP 400. - const params: Record = { - attachments: [], - language: "en-US", - timezone: tz, - search_focus: "internet", - sources: ["web"], - frontend_uuid: requestId, - mode, - model_preference: modelPref, - is_related_query: false, - is_sponsored: false, - frontend_context_uuid: crypto.randomUUID(), - prompt_source: "user", - query_source: "home", - is_incognito: true, - local_search_enabled: false, - use_schematized_api: true, - send_back_text_in_streaming_api: false, - supported_block_use_cases: PPLX_SUPPORTED_BLOCK_USE_CASES, - client_coordinates: null, - mentions: [], - dsl_query: dslQuery && dslQuery.trim() ? dslQuery : query, - skip_search_enabled: true, - is_nav_suggestions_disabled: false, - source: "default", - always_search_override: false, - override_no_search: false, - client_search_results_cache_key: requestId, - should_ask_for_mcp_tool_confirmation: true, - browser_agent_allow_once_from_toggle: false, - force_enable_browser_agent: false, - supported_features: ["browser_agent_permission_banner_v1.1"], - extended_context: false, - version: PPLX_API_VERSION, - rum_session_id: crypto.randomUUID(), - }; - - // Only present on follow-ups (matches the browser, which omits it for a fresh query). - if (followUpUuid) { - params.last_backend_uuid = followUpUuid; - } - - return { - query_str: query, - params, - }; -} - -function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string { - if (followUpUuid) return parsed.currentMsg; - - const obj: Record = {}; - if (parsed.systemMsg.trim()) { - obj.instructions = [ - parsed.systemMsg.trim(), - "You have built-in web search. Answer questions directly using search results.", - ]; - } - if (parsed.history.length > 0) { - obj.history = parsed.history; - } - if (parsed.currentMsg) { - obj.query = parsed.currentMsg; - } else if (parsed.history.length === 0) { - obj.query = ""; - } - const json = JSON.stringify(obj); - return json.length > 96000 ? json.slice(-96000) : json; -} - -// ─── Content extraction ───────────────────────────────────────────────────── - -interface ContentChunk { - delta?: string; - answer?: string; - backendUuid?: string; - thinking?: string; - error?: string; - done?: boolean; -} - -// The schematized API delivers the answer text in blocks whose `intended_usage` -// is either the aggregate `ask_text` or per-segment `ask_text__markdown` -// (older builds used names merely containing "markdown"). All converge on the -// same answer, so we lock onto a single primary usage to avoid double-counting. -function isAnswerTextUsage(usage: string): boolean { - return ( - usage === "ask_text" || /^ask_text_\d+_markdown$/.test(usage) || usage.includes("markdown") - ); -} - -// Reconstructed state for one answer-text block, built up from diff patches -// (streaming) or a materialized markdown_block (final COMPLETED frame). -interface MarkdownAccumulator { - chunks: string[]; -} - -// Apply a markdown_block diff_block patch set. Perplexity sends an initial -// `{op:"replace", path:"", value:{chunks:[...]}}` then incremental -// `{op:"add", path:"/chunks/", value:"..."}` frames. We only need the -// chunks array; joining it yields the cumulative answer text. -function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPatch[]): void { - for (const patch of patches) { - const path = patch.path ?? ""; - if (path === "") { - const value = (patch.value ?? {}) as { chunks?: unknown }; - acc.chunks = Array.isArray(value.chunks) ? value.chunks.map((c) => String(c)) : []; - continue; - } - const chunkMatch = /^\/chunks\/(\d+)$/.exec(path); - if (chunkMatch && typeof patch.value === "string") { - const idx = Number.parseInt(chunkMatch[1], 10); - acc.chunks[idx] = patch.value; - } - } -} - -async function* extractContent( - eventStream: ReadableStream, - signal?: AbortSignal | null -): AsyncGenerator { - let fullAnswer = ""; - let backendUuid: string | null = null; - let seenLen = 0; - const seenThinking = new Set(); - // Per-usage reconstructed answer-text blocks + the locked primary usage. - const mdState = new Map(); - let primaryUsage: string | null = null; - - for await (const event of readPplxSseEvents(eventStream, signal)) { - if (event.error_code || event.error_message) { - yield { - error: event.error_message || `Perplexity error: ${event.error_code}`, - done: true, - }; - return; - } - - if (event.backend_uuid) backendUuid = event.backend_uuid; - - const blocks = event.blocks ?? []; - for (const block of blocks) { - const usage = block.intended_usage ?? ""; - - // Thinking: search steps - if (usage === "pro_search_steps" && block.plan_block?.steps) { - for (const step of block.plan_block.steps) { - if (step.step_type === "SEARCH_WEB") { - for (const q of step.search_web_content?.queries ?? []) { - const qr = q.query ?? ""; - if (qr && !seenThinking.has(qr)) { - seenThinking.add(qr); - yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; - } - } - } else if (step.step_type === "READ_RESULTS") { - for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { - if (u && !seenThinking.has(u)) { - seenThinking.add(u); - yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; - } - } - } - } - } - - // Thinking: plan goals - if (usage === "plan" && block.plan_block?.goals) { - for (const goal of block.plan_block.goals) { - const desc = goal.description ?? ""; - if (desc && !seenThinking.has(desc)) { - seenThinking.add(desc); - yield { thinking: desc, backendUuid: backendUuid ?? undefined }; - } - } - } - - // Content: answer-text blocks (schematized diff frames OR materialized - // markdown_block on the final COMPLETED frame). - if (!isAnswerTextUsage(usage)) continue; - let acc = mdState.get(usage); - if (!acc) { - acc = { chunks: [] }; - mdState.set(usage, acc); - } - - if (block.diff_block && Array.isArray(block.diff_block.patches)) { - applyMarkdownDiff(acc, block.diff_block.patches); - } else if (block.markdown_block) { - const mb = block.markdown_block; - if (Array.isArray(mb.chunks) && mb.chunks.length > 0) { - acc.chunks = mb.chunks.map((c) => String(c)); - } else if (typeof mb.answer === "string" && mb.answer.length > 0) { - acc.chunks = [mb.answer]; - } - } - - // Prefer the aggregate `ask_text` block; otherwise lock the first seen. - if (usage === "ask_text") { - primaryUsage = "ask_text"; - } else if (!primaryUsage) { - primaryUsage = usage; - } - } - - // Emit at most one content delta per event, from the locked primary usage. - if (primaryUsage) { - const currentAnswer = (mdState.get(primaryUsage)?.chunks ?? []).join(""); - if (currentAnswer.length > seenLen) { - const delta = currentAnswer.slice(seenLen); - fullAnswer = currentAnswer; - seenLen = currentAnswer.length; - yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; - } - } - - // Legacy fallback: a plain non-JSON `text` field with no structured blocks. - // The schematized API's `text` field is a JSON step-blob (not user-facing), - // so only use it when there are no answer-text blocks at all. - if (!primaryUsage && blocks.length === 0 && event.text) { - const t = event.text.trim(); - const looksLikeJson = t.startsWith("{") || t.startsWith("["); - if (!looksLikeJson && t.length > seenLen) { - const delta = t.slice(seenLen); - fullAnswer = t; - seenLen = t.length; - yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; - } - } - - // Only stop on the terminal COMPLETED frame. A `final:true` flag can appear - // on a still-PENDING frame BEFORE the COMPLETED frame that materializes the - // full markdown_block — breaking on `final` there drops the answer. - if (event.status === "COMPLETED") break; - } - - yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; -} - -// ─── OpenAI SSE format ────────────────────────────────────────────────────── - -function sseChunk(data: unknown): string { - return `data: ${JSON.stringify(data)}\n\n`; -} - function buildStreamingResponse( eventStream: ReadableStream, model: string, @@ -965,8 +483,29 @@ export class PerplexityWebExecutor extends BaseExecutor { const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); + // Tool mode buffers the full completion (no live token streaming) and + // converts text into real tool_calls — even when the caller asked + // for a streaming response — mirroring chatgpt-web's toolMode (#5240, + // #5927). Without this, streaming requests (the default for agentic + // coding clients) never emitted a tool_calls SSE delta. let finalResponse: Response; - if (stream) { + if (hasTools) { + const bufferedJson = await buildNonStreamingResponse( + response.body, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + finalResponse = await buildToolModeResponse(bufferedJson, requestedTools, stream, { + cid, + created, + model, + idSeed: "pplx", + }); + } else if (stream) { const sseStream = buildStreamingResponse( response.body, model, @@ -996,31 +535,6 @@ export class PerplexityWebExecutor extends BaseExecutor { ); } - if (hasTools && !stream) { - const bodyText = await (finalResponse as Response).text(); - try { - const json = JSON.parse(bodyText); - const rawContent = json?.choices?.[0]?.message?.content || ""; - const { content, toolCalls, finishReason } = buildToolAwareResult( - rawContent, - requestedTools, - "pplx" - ); - if (toolCalls) { - json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; - json.choices[0].finish_reason = finishReason; - } else { - json.choices[0].message.content = content; - } - finalResponse = new Response(JSON.stringify(json), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch { - /* keep original response */ - } - } - return { response: finalResponse, url: PPLX_SSE_ENDPOINT, diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts new file mode 100644 index 0000000000..102ef3f9b9 --- /dev/null +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -0,0 +1,498 @@ +// Pure Perplexity wire protocol: consts, types, SSE parsing, request/query building, +// content extraction. Extracted verbatim from perplexity-web.ts. No host state/fetch/auth. +import { randomUUID } from "crypto"; + +export const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; +// Perplexity's current request schema version (sent in params.version). Perplexity rejects +// stale versions with HTTP 400 — keep this in lockstep with the website's payload. +export const PPLX_API_VERSION = "2.18"; +// Block use-cases the current web client advertises. The schematized API (use_schematized_api) +// validates the request shape, so this must be present (mirrors the browser request body). +export const PPLX_SUPPORTED_BLOCK_USE_CASES = [ + "answer_modes", + "media_items", + "knowledge_cards", + "inline_entity_cards", + "place_widgets", + "finance_widgets", + "sports_widgets", + "news_widgets", + "shopping_widgets", + "jobs_widgets", + "search_result_widgets", + "inline_images", + "inline_assets", + "placeholder_cards", + "diff_blocks", + "inline_knowledge_cards", + "entity_group_v2", + "refinement_filters", + "canvas_mode", + "maps_preview", + "answer_tabs", + "price_comparison_widgets", + "preserve_latex", + "generic_onboarding_widgets", + "in_context_suggestions", + "pending_followups", + "inline_claims", + "unified_assets", + "workflow_steps", + "background_agents", +]; +// Firefox 148 — must match the `firefox_148` TLS profile used by perplexityTlsClient. +// A mismatched UA vs TLS fingerprint is itself a Cloudflare bot signal (issue #2459). +export const PPLX_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"; + +export const MODEL_MAP: Record = { + "pplx-auto": ["concise", "pplx_pro"], + "pplx-sonar": ["copilot", "experimental"], + "pplx-gpt": ["copilot", "gpt54"], + "pplx-gemini": ["copilot", "gemini31pro_high"], + "pplx-sonnet": ["copilot", "claude46sonnet"], + "pplx-opus": ["copilot", "claude46opus"], + "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], +}; + +export const THINKING_MAP: Record = { + "pplx-gpt": "gpt54_thinking", + "pplx-sonnet": "claude46sonnetthinking", + "pplx-opus": "claude46opusthinking", +}; + +export const CITATION_RE = /\[\d+\]/g; +export const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; +export const GROK_SELF_RE = /]*\/>/g; +export const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; +export const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; +export const MULTI_SPACE = / {2,}/g; +export const MULTI_NL = /\n{3,}/g; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +export function cleanResponse(text: string, strip = true): string { + let t = text; + t = t.replace(XML_DECL_RE, ""); + t = t.replace(CITATION_RE, ""); + t = t.replace(GROK_TAG_RE, ""); + t = t.replace(GROK_SELF_RE, ""); + t = t.replace(RESPONSE_TAG_RE, ""); + if (strip) { + t = t.replace(MULTI_SPACE, " "); + t = t.replace(MULTI_NL, "\n\n"); + t = t.trim(); + } + return t; +} + +// ─── SSE types ────────────────────────────────────────────────────────────── + +export interface PplxDiffPatch { + op?: string; + path?: string; + value?: unknown; +} + +export interface PplxBlock { + intended_usage?: string; + markdown_block?: { + answer?: string; + chunks?: string[]; + progress?: string; + chunk_starting_offset?: number; + }; + // Schematized API (use_schematized_api) streams block updates as RFC-6902 + // JSON-patch diffs against a target field (e.g. markdown_block) instead of + // sending the whole block each frame. `field` names the block being patched. + diff_block?: { + field?: string; + patches?: PplxDiffPatch[]; + }; + web_result_block?: { + web_results?: Array<{ url?: string; name?: string; snippet?: string }>; + }; + plan_block?: { + steps?: Array<{ + step_type?: string; + search_web_content?: { queries?: Array<{ query?: string }> }; + read_results_content?: { urls?: string[] }; + }>; + goals?: Array<{ description?: string }>; + }; +} + +export interface PplxStreamEvent { + status?: string; + final?: boolean; + text?: string; + blocks?: PplxBlock[]; + backend_uuid?: string; + web_results?: Array<{ url?: string; name?: string }>; + error_code?: string; + error_message?: string; + display_model?: string; +} + +// ─── SSE parsing ──────────────────────────────────────────────────────────── + +export async function* readPplxSseEvents( + body: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines: string[] = []; + + function flush(): PplxStreamEvent | null | "done" { + if (dataLines.length === 0) return null; + const payload = dataLines.join("\n"); + dataLines = []; + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return "done"; + try { + return JSON.parse(trimmed) as PplxStreamEvent; + } catch { + return null; + } + } + + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const rawLine = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + if (line === "") { + const parsed = flush(); + if (parsed === "done") return; + if (parsed) yield parsed; + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + if (line === "event: end_of_stream") { + return; + } + } + } + + buffer += decoder.decode(); + if (buffer.trim().startsWith("data:")) { + dataLines.push(buffer.trim().slice(5).trimStart()); + } + const tail = flush(); + if (tail && tail !== "done") yield tail; + } finally { + reader.releaseLock(); + } +} + +// ─── OpenAI → Perplexity translation ──────────────────────────────────────── + +export interface ParsedMessages { + systemMsg: string; + history: Array<{ role: string; content: string }>; + currentMsg: string; +} + +export function parseOpenAIMessages(messages: Array>): ParsedMessages { + let systemMsg = ""; + const history: Array<{ role: string; content: string }> = []; + + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = (msg.content as Array>) + .filter((c) => c.type === "text") + .map((c) => String(c.text || "")) + .join(" "); + } + if (!content.trim()) continue; + + if (role === "system") { + systemMsg += content + "\n"; + } else if (role === "user" || role === "assistant") { + history.push({ role, content }); + } + } + + let currentMsg = ""; + if (history.length > 0 && history[history.length - 1].role === "user") { + currentMsg = history.pop()!.content; + } + + return { systemMsg, history, currentMsg }; +} + +export function buildPplxRequestBody( + query: string, + dslQuery: string, + mode: string, + modelPref: string, + followUpUuid: string | null, + requestId: string +): Record { + const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; + + // Mirrors the current www.perplexity.ai/rest/sse/perplexity_ask request body. Perplexity's + // schematized API validates this shape; an outdated version or missing required fields → HTTP 400. + const params: Record = { + attachments: [], + language: "en-US", + timezone: tz, + search_focus: "internet", + sources: ["web"], + frontend_uuid: requestId, + mode, + model_preference: modelPref, + is_related_query: false, + is_sponsored: false, + frontend_context_uuid: crypto.randomUUID(), + prompt_source: "user", + query_source: "home", + is_incognito: true, + local_search_enabled: false, + use_schematized_api: true, + send_back_text_in_streaming_api: false, + supported_block_use_cases: PPLX_SUPPORTED_BLOCK_USE_CASES, + client_coordinates: null, + mentions: [], + dsl_query: dslQuery && dslQuery.trim() ? dslQuery : query, + skip_search_enabled: true, + is_nav_suggestions_disabled: false, + source: "default", + always_search_override: false, + override_no_search: false, + client_search_results_cache_key: requestId, + should_ask_for_mcp_tool_confirmation: true, + browser_agent_allow_once_from_toggle: false, + force_enable_browser_agent: false, + supported_features: ["browser_agent_permission_banner_v1.1"], + extended_context: false, + version: PPLX_API_VERSION, + rum_session_id: crypto.randomUUID(), + }; + + // Only present on follow-ups (matches the browser, which omits it for a fresh query). + if (followUpUuid) { + params.last_backend_uuid = followUpUuid; + } + + return { + query_str: query, + params, + }; +} + +export function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string { + if (followUpUuid) return parsed.currentMsg; + + const obj: Record = {}; + if (parsed.systemMsg.trim()) { + obj.instructions = [ + parsed.systemMsg.trim(), + "You have built-in web search. Answer questions directly using search results.", + ]; + } + if (parsed.history.length > 0) { + obj.history = parsed.history; + } + if (parsed.currentMsg) { + obj.query = parsed.currentMsg; + } else if (parsed.history.length === 0) { + obj.query = ""; + } + const json = JSON.stringify(obj); + return json.length > 96000 ? json.slice(-96000) : json; +} + +// ─── Content extraction ───────────────────────────────────────────────────── + +export interface ContentChunk { + delta?: string; + answer?: string; + backendUuid?: string; + thinking?: string; + error?: string; + done?: boolean; +} + +// The schematized API delivers the answer text in blocks whose `intended_usage` +// is either the aggregate `ask_text` or per-segment `ask_text__markdown` +// (older builds used names merely containing "markdown"). All converge on the +// same answer, so we lock onto a single primary usage to avoid double-counting. +export function isAnswerTextUsage(usage: string): boolean { + return ( + usage === "ask_text" || /^ask_text_\d+_markdown$/.test(usage) || usage.includes("markdown") + ); +} + +// Reconstructed state for one answer-text block, built up from diff patches +// (streaming) or a materialized markdown_block (final COMPLETED frame). +export interface MarkdownAccumulator { + chunks: string[]; +} + +// Apply a markdown_block diff_block patch set. Perplexity sends an initial +// `{op:"replace", path:"", value:{chunks:[...]}}` then incremental +// `{op:"add", path:"/chunks/", value:"..."}` frames. We only need the +// chunks array; joining it yields the cumulative answer text. +export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPatch[]): void { + for (const patch of patches) { + const path = patch.path ?? ""; + if (path === "") { + const value = (patch.value ?? {}) as { chunks?: unknown }; + acc.chunks = Array.isArray(value.chunks) ? value.chunks.map((c) => String(c)) : []; + continue; + } + const chunkMatch = /^\/chunks\/(\d+)$/.exec(path); + if (chunkMatch && typeof patch.value === "string") { + const idx = Number.parseInt(chunkMatch[1], 10); + acc.chunks[idx] = patch.value; + } + } +} + +export async function* extractContent( + eventStream: ReadableStream, + signal?: AbortSignal | null +): AsyncGenerator { + let fullAnswer = ""; + let backendUuid: string | null = null; + let seenLen = 0; + const seenThinking = new Set(); + // Per-usage reconstructed answer-text blocks + the locked primary usage. + const mdState = new Map(); + let primaryUsage: string | null = null; + + for await (const event of readPplxSseEvents(eventStream, signal)) { + if (event.error_code || event.error_message) { + yield { + error: event.error_message || `Perplexity error: ${event.error_code}`, + done: true, + }; + return; + } + + if (event.backend_uuid) backendUuid = event.backend_uuid; + + const blocks = event.blocks ?? []; + for (const block of blocks) { + const usage = block.intended_usage ?? ""; + + // Thinking: search steps + if (usage === "pro_search_steps" && block.plan_block?.steps) { + for (const step of block.plan_block.steps) { + if (step.step_type === "SEARCH_WEB") { + for (const q of step.search_web_content?.queries ?? []) { + const qr = q.query ?? ""; + if (qr && !seenThinking.has(qr)) { + seenThinking.add(qr); + yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; + } + } + } else if (step.step_type === "READ_RESULTS") { + for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { + if (u && !seenThinking.has(u)) { + seenThinking.add(u); + yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; + } + } + } + } + } + + // Thinking: plan goals + if (usage === "plan" && block.plan_block?.goals) { + for (const goal of block.plan_block.goals) { + const desc = goal.description ?? ""; + if (desc && !seenThinking.has(desc)) { + seenThinking.add(desc); + yield { thinking: desc, backendUuid: backendUuid ?? undefined }; + } + } + } + + // Content: answer-text blocks (schematized diff frames OR materialized + // markdown_block on the final COMPLETED frame). + if (!isAnswerTextUsage(usage)) continue; + let acc = mdState.get(usage); + if (!acc) { + acc = { chunks: [] }; + mdState.set(usage, acc); + } + + if (block.diff_block && Array.isArray(block.diff_block.patches)) { + applyMarkdownDiff(acc, block.diff_block.patches); + } else if (block.markdown_block) { + const mb = block.markdown_block; + if (Array.isArray(mb.chunks) && mb.chunks.length > 0) { + acc.chunks = mb.chunks.map((c) => String(c)); + } else if (typeof mb.answer === "string" && mb.answer.length > 0) { + acc.chunks = [mb.answer]; + } + } + + // Prefer the aggregate `ask_text` block; otherwise lock the first seen. + if (usage === "ask_text") { + primaryUsage = "ask_text"; + } else if (!primaryUsage) { + primaryUsage = usage; + } + } + + // Emit at most one content delta per event, from the locked primary usage. + if (primaryUsage) { + const currentAnswer = (mdState.get(primaryUsage)?.chunks ?? []).join(""); + if (currentAnswer.length > seenLen) { + const delta = currentAnswer.slice(seenLen); + fullAnswer = currentAnswer; + seenLen = currentAnswer.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + + // Legacy fallback: a plain non-JSON `text` field with no structured blocks. + // The schematized API's `text` field is a JSON step-blob (not user-facing), + // so only use it when there are no answer-text blocks at all. + if (!primaryUsage && blocks.length === 0 && event.text) { + const t = event.text.trim(); + const looksLikeJson = t.startsWith("{") || t.startsWith("["); + if (!looksLikeJson && t.length > seenLen) { + const delta = t.slice(seenLen); + fullAnswer = t; + seenLen = t.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + + // Only stop on the terminal COMPLETED frame. A `final:true` flag can appear + // on a still-PENDING frame BEFORE the COMPLETED frame that materializes the + // full markdown_block — breaking on `final` there drops the answer. + if (event.status === "COMPLETED") break; + } + + yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; +} + +// ─── OpenAI SSE format ────────────────────────────────────────────────────── + +export function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index f52faad70c..62ac641e90 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -10,8 +10,17 @@ import { getQoderDashscopeCompatHeaders, QODER_DEFAULT_USER_AGENT, } from "../config/providerHeaderProfiles.ts"; +import { randomUUID } from "node:crypto"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; -import { buildCosyHeadersForValidation, resolveQoderJobToken } from "../services/qoderCli.ts"; +import { + buildQoderChunk, + buildQoderCompletionPayload, + buildQoderPrompt, + createQoderErrorResponse, + parseQoderCliFailure, + parseQoderCliResult, + runQoderCli, +} from "../services/qoderCli.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; function truncate(text: string, max: number): string { @@ -19,6 +28,33 @@ function truncate(text: string, max: number): string { return `${text.slice(0, max)}…`; } +/** + * Wrap a full qodercli reply as an OpenAI-compatible SSE stream (role chunk → + * content chunk → stop chunk → [DONE]). qodercli's `--print` mode returns the + * whole answer at once, so there are no incremental deltas to forward. + */ +function buildQoderCliSseStream(model: string, text: string): ReadableStream { + const id = `chatcmpl-${randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + const encoder = new TextEncoder(); + const send = (obj: unknown) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`); + return new ReadableStream({ + start(controller) { + controller.enqueue( + send(buildQoderChunk({ id, model, created, delta: { role: "assistant", content: "" } })) + ); + if (text) { + controller.enqueue(send(buildQoderChunk({ id, model, created, delta: { content: text } }))); + } + controller.enqueue( + send(buildQoderChunk({ id, model, created, delta: {}, finishReason: "stop" })) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); +} + /** * Peek at the first SSE event from a Qoder response to detect upstream errors * that Qoder wraps inside an HTTP 200 SSE envelope ({statusCodeValue, body}). @@ -174,23 +210,24 @@ export class QoderExecutor extends BaseExecutor { const resolvedModel = model || "qwen3-coder-plus"; - // Detect token type: PAT (Personal Access Token) starts with "pt-" + // Detect token type: PAT (Personal Access Token) starts with "pt-". + // PATs are driven through the local qodercli binary (see executeViaQoderCli); + // only the qodercli binary can produce the WASM-signed Cosy request the raw + // HTTP path can no longer replicate. const isPatToken = token.startsWith("pt-"); - - let mappedModel = resolvedModel; - let endpointUrl: string; - if (isPatToken) { - endpointUrl = "https://api.qoder.com/v1/chat/completions"; - } else { - if (resolvedModel === "qwen3.5-plus" || resolvedModel === "qwen3.6-plus") { - mappedModel = "coder-model"; - } else if (resolvedModel === "vision-model") { - mappedModel = "qwen3-vl-plus"; - } - endpointUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; + return this.executeViaQoderCli({ model: resolvedModel, body, stream, token, signal }); } + // Non-PAT tokens (OAuth apiKey / DashScope key) → DashScope OpenAI-compatible API. + let mappedModel = resolvedModel; + if (resolvedModel === "qwen3.5-plus" || resolvedModel === "qwen3.6-plus") { + mappedModel = "coder-model"; + } else if (resolvedModel === "vision-model") { + mappedModel = "qwen3-vl-plus"; + } + let endpointUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; + // Check for custom API base via credentials (overrides the default) let credentialsApiBase: unknown; if (typeof credentials === "object" && credentials !== null) { @@ -207,91 +244,23 @@ export class QoderExecutor extends BaseExecutor { const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, - ...(isPatToken ? {} : getQoderDashscopeCompatHeaders()), + ...getQoderDashscopeCompatHeaders(), }; mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const payload = this.transformRequest(mappedModel, body, stream, credentials); + const payload = this.transformRequest(mappedModel, body); const bodyStr = JSON.stringify(payload); try { - let response = await fetch(endpointUrl, { + const response = await fetch(endpointUrl, { method: "POST", headers, body: bodyStr, signal, }); - // PAT tokens (pt-*) are not accepted as Bearer tokens by api.qoder.com/v1/chat/completions. - // They return 401 TOKEN_INVALID. Fallback to Cosy auth against api1.qoder.sh. - if (!response.ok && response.status === 401 && isPatToken) { - // #4683: exchange the PAT (pt-*) for a job token (jt-*) before the Cosy call; - // Cosy rejects a raw pt-* in security_oauth_token with a generic 500. - const cosyToken = await resolveQoderJobToken(token, { signal }); - const cosyHeaders = buildCosyHeadersForValidation(bodyStr, cosyToken); - const cosyEndpoint = - "https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common"; - const cosyRes = await fetch(cosyEndpoint, { - method: "POST", - headers: cosyHeaders, - body: bodyStr, - signal, - }); - - if (cosyRes.ok || cosyRes.status === 200) { - // Cosy SSE response - read full body and parse - const rawText = await cosyRes.text(); - const lines = rawText.split("\n").filter((l) => l.startsWith("data: ")); - let fullContent = ""; - for (const line of lines) { - try { - const jsonData = JSON.parse(line.slice(6)); - const { extractTextFromQoderEnvelope } = await import("../services/qoderCli.ts"); - const chunkText = extractTextFromQoderEnvelope(jsonData); - if (chunkText) fullContent += chunkText; - } catch { - // skip unparseable chunks - } - } - const { buildQoderCompletionPayload } = await import("../services/qoderCli.ts"); - const cosyPayload = buildQoderCompletionPayload({ - model: mappedModel || resolvedModel, - text: fullContent, - }); - return { - response: new Response(JSON.stringify(cosyPayload), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - url: cosyEndpoint, - headers: cosyHeaders, - transformedBody: payload, - }; - } - - // Cosy also failed - return the original 401 error - let errText = await cosyRes.text(); - return { - response: new Response( - JSON.stringify({ - error: { - message: - `Qoder API (Cosy) failed with status ${cosyRes.status}: ${errText}. Your PAT token may not be valid for the chat API.` + - " Try using an OAuth token or a different auth method.", - type: "authentication_error", - code: "token_invalid", - }, - }), - { status: 401, headers: { "Content-Type": "application/json" } } - ), - url: cosyEndpoint, - headers: cosyHeaders, - transformedBody: payload, - }; - } - if (!response.ok) { let errText = await response.text(); return { @@ -341,6 +310,102 @@ export class QoderExecutor extends BaseExecutor { }; } } + + /** + * Drive a PAT (`pt-*`) completion through the local qodercli binary. The CLI + * performs Qoder's WASM-signed Cosy auth internally, so this is the only path + * that works for PATs now that the pure-HTTP Cosy reimplementation is dead. + */ + private async executeViaQoderCli({ + model, + body, + stream, + token, + signal, + }: { + model: string; + body: unknown; + stream: boolean; + token: string; + signal?: AbortSignal | null; + }): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const url = "qodercli://stdio"; + const prompt = buildQoderPrompt(body); + + const run = await runQoderCli({ token, prompt, stream: false, model, signal }); + + // Honor client cancellation the same way the HTTP path does. + if (signal?.aborted) { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + throw abortError; + } + + if (run.error && /enoent|not found|no such file|spawn/i.test(run.error)) { + return { + response: createQoderErrorResponse({ + status: 502, + message: + `Qoder CLI (qodercli) was not found on the OmniRoute host (${run.error}). ` + + "Install it from https://qoder.com or set CLI_QODER_BIN to its path.", + code: "cli_not_found", + }), + url, + headers: {}, + transformedBody: body, + }; + } + + if (!run.ok) { + return { + response: createQoderErrorResponse(parseQoderCliFailure(run.stderr, run.stdout)), + url, + headers: {}, + transformedBody: body, + }; + } + + const { text, isError, errorMessage } = parseQoderCliResult(run.stdout); + if (isError) { + return { + response: createQoderErrorResponse(parseQoderCliFailure(errorMessage)), + url, + headers: {}, + transformedBody: body, + }; + } + + if (stream) { + return { + response: new Response(buildQoderCliSseStream(model, text), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url, + headers: {}, + transformedBody: body, + }; + } + + return { + response: new Response(JSON.stringify(buildQoderCompletionPayload({ model, text })), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url, + headers: {}, + transformedBody: body, + }; + } } export default QoderExecutor; diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index a61072d283..485eb93758 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -58,7 +58,9 @@ const MODEL_ALIASES: Record = { "qwen3-plus": "qwen3.7-plus", "qwen3-max": "qwen3.7-max", "qwen3-flash": "qwen3.6-plus", - "qwen3-coder-plus": "qwen3.7-max", + // Note: `qwen3-coder-plus` is a real upstream model id (Qwen3-Coder) and + // must NOT be aliased — the previous `"qwen3-coder-plus": "qwen3.7-max"` + // entry silently rewrote valid coder requests to the wrong model. "qwen3-coder-flash": "qwen3.6-plus", qwen: "qwen3.7-max", qwen3: "qwen3.7-max", diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 46804cdf77..64e985b96c 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -39,7 +39,44 @@ const CLAUDE_NAMES: Record = { "claude haiku 3.5": "CLAUDE_4_5_HAIKU", }; -function mapModel(model: string): string { +// Canonical upstream model IDs served by theoldllm's /api/chatgpt proxy +// (apiProvider "chatgpt" in the site's model catalog — the free, reachable tier). +// Source: https://theoldllm.vercel.app model list (reported in #5181). +// These pass through mapModel() UNCHANGED — critical for non-GPT/Claude models +// (Gemini, o-series, Grok, DeepSeek, Sonar) which would otherwise fall through +// to the GPT_5_4 default and silently misroute. +export const CHATGPT_UPSTREAM_MODELS: ReadonlySet = new Set([ + "GPT_5_4", + "GPT_5_3", + "GPT_5_2", + "GPT_5_1", + "GPT_5", + "GPT_o4_mini", + "GPT_o3_mini", + "gemini_3_pro", + "gemini_2_5_pro", + "gemini_2_0_flash", + "gemini_1_5_flash", + "CLAUDE_4_6_OPUS", + "CLAUDE_4_6_SONNET", + "CLAUDE_4_5_HAIKU", + "openrouter_gpt_4_o", + "openrouter_gpt_4_o_mini", + "openrouter_gpt_4", + "openrouter_grok_4", + "together_deepseek_r1", + "openrouter_deepseek_r1", + "together_deepseek_v3", + "openrouter_deepseek_v3", + "sonar-deep-research", + "sonar-pro", + "openrouter_web_search", +]); + +export function mapModel(model: string): string { + const trimmed = model.trim(); + // Known upstream IDs (from live discovery / refreshed catalog) route as-is. + if (CHATGPT_UPSTREAM_MODELS.has(trimmed)) return trimmed; const n = model.toLowerCase().trim(); const gptKey = n.replace(/[_\s]+/g, "-"); if (GPT_MODELS[gptKey]) return GPT_MODELS[gptKey]; diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts new file mode 100644 index 0000000000..9f806e2c5a --- /dev/null +++ b/open-sse/executors/xai.ts @@ -0,0 +1,94 @@ +import { BaseExecutor, type ProviderCredentials } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; + +type JsonRecord = Record; + +/** + * xAI/Grok model ids (open-sse/config/providers/registry/xai/index.ts) that accept + * a graduated `reasoning_effort`. Kept narrow and reconciled against the REAL + * catalog rather than upstream's example ids (grok-4/grok-3 do not exist here): + * - grok-4.3 — current-generation flagship, reasoning-capable. + * - grok-4.20-0309-reasoning — explicit reasoning variant. + * + * grok-4.20-multi-agent-0309 is intentionally left unclassified (neither allow + * nor deny): its reasoning support is not documented in the local catalog, so + * we pass it through unchanged rather than guess. + */ +const REASONING_ALLOWED = ["grok-4.3", "grok-4.20-0309-reasoning"]; + +/** + * Model ids that reject `reasoning_effort` outright: + * - grok-build-0.1 — build/tool-oriented model, no reasoning mode. + * - grok-4.20-0309-non-reasoning — already encodes "no reasoning" in the id; + * forwarding reasoning_effort here would be redundant/rejected upstream. + */ +const REASONING_DENIED = ["grok-build-0.1", "grok-4.20-0309-non-reasoning"]; + +/** `-{level}` suffixes some clients append to a model id to select reasoning intensity. */ +const EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh"] as const; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +/** + * xAI/Grok executor (port of decolua/9router#2147). + * + * Some Grok clients select reasoning intensity via a `-{low,medium,high,xhigh}` + * suffix on the model id (e.g. `grok-4.3-high`) rather than a native + * `reasoning_effort` field — xAI itself does not recognize the suffixed id. + * This executor: + * 1. Parses and strips that suffix off the model id before the request + * reaches xAI, mapping it to `reasoning_effort` for allow-listed models. + * 2. Strips any `reasoning_effort` for deny-listed models — including ids + * that already encode their reasoning state in the name (`-reasoning` / + * `-non-reasoning`), which must not be double-mutated by also stacking a + * `reasoning_effort` field on top of what the id already declares. + * 3. Leaves unclassified models and bodies untouched otherwise. + */ +export class XaiExecutor extends BaseExecutor { + constructor() { + super("xai", PROVIDERS.xai); + } + + transformRequest( + model: string, + body: unknown, + stream: boolean, + credentials: ProviderCredentials + ): unknown { + const cleaned = super.transformRequest(model, body, stream, credentials); + const record = asRecord(cleaned); + if (!record) return cleaned; + + const out: JsonRecord = { ...record }; + let modelId = typeof out.model === "string" ? out.model : model; + + let suffixEffort: string | null = null; + for (const level of EFFORT_SUFFIXES) { + const suffix = `-${level}`; + if (modelId.endsWith(suffix)) { + suffixEffort = level; + modelId = modelId.slice(0, -suffix.length); + break; + } + } + if (suffixEffort && typeof out.model === "string") { + out.model = modelId; + } + + const isDenied = REASONING_DENIED.some((id) => modelId.includes(id)); + const isAllowed = REASONING_ALLOWED.some((id) => modelId.includes(id)); + + if (isDenied) { + delete out.reasoning_effort; + } else if (isAllowed) { + const effort = suffixEffort || out.reasoning_effort; + if (effort) out.reasoning_effort = effort; + } + + return out; + } +} + +export default XaiExecutor; diff --git a/open-sse/handlers/audioTranslation.ts b/open-sse/handlers/audioTranslation.ts new file mode 100644 index 0000000000..7269ffb006 --- /dev/null +++ b/open-sse/handlers/audioTranslation.ts @@ -0,0 +1,135 @@ +/** + * Audio Translation Handler + * + * Handles POST /v1/audio/translations (Whisper translate-to-English API + * format). Proxies multipart/form-data to upstream providers that expose an + * OpenAI-Whisper-compatible /audio/translations endpoint. + * + * Unlike /v1/audio/transcriptions, translation always outputs English text + * regardless of the source audio language, so there is no `language` input + * field — only `model`, `file`, `prompt`, `response_format`, and + * `temperature` are forwarded upstream. + */ + +import { + getTranslationProvider, + parseTranslationModel, + type AudioProvider, +} from "../config/audioRegistry.ts"; +import { buildAuthHeaders } from "../config/registryUtils.ts"; +import { buildMultipartBody } from "./audioTranscription.ts"; +import { errorResponse } from "../utils/error.ts"; + +type TranslationCredentials = { + apiKey?: string; + accessToken?: string; +}; + +/** + * Extract a readable error message from an upstream provider's error body. + */ +function extractUpstreamErrorMessage(errText: string, status: number): string { + try { + const parsed = JSON.parse(errText); + const raw = + parsed?.error?.message || + (typeof parsed?.error === "string" ? parsed.error : null) || + parsed?.message || + null; + return raw ? String(raw) : errText || `Upstream error (${status})`; + } catch { + return errText || `Upstream error (${status})`; + } +} + +/** + * Handle audio translation request + * + * @param {Object} options + * @param {FormData} options.formData - Multipart form data with file + model + * @param {Object} options.credentials - Provider credentials { apiKey } + * @returns {Response} + */ +export async function handleAudioTranslation({ + formData, + credentials, + resolvedProvider = null, + resolvedModel = null, +}: { + formData: FormData; + credentials?: TranslationCredentials | null; + resolvedProvider?: AudioProvider | null; + resolvedModel?: string | null; +}): Promise { + const model = formData.get("model"); + if (typeof model !== "string" || !model) { + return errorResponse(400, "model is required"); + } + + const fileEntry = formData.get("file"); + if (!(fileEntry instanceof Blob)) { + return errorResponse(400, "file is required"); + } + const file = fileEntry as Blob & { name?: unknown }; + + // Use pre-resolved provider/model from route handler if available. + let providerConfig = resolvedProvider; + let modelId = resolvedModel; + if (!providerConfig) { + const parsed = parseTranslationModel(model); + providerConfig = parsed.provider ? getTranslationProvider(parsed.provider) : null; + modelId = parsed.model; + } + + if (!providerConfig) { + return errorResponse( + 400, + `No translation provider found for model "${model}". Available: openai, groq` + ); + } + + const token = + providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; + if (providerConfig.authType !== "none" && !token) { + return errorResponse(401, `No credentials for translation provider: ${providerConfig.id}`); + } + + // OpenAI Whisper translate-to-English params — no `language`, output is + // always English regardless of the source audio language. + const extraFields: Record = {}; + for (const key of ["prompt", "response_format", "temperature"]) { + const val = formData.get(key); + if (val !== null && val !== undefined) { + extraFields[key] = String(val); + } + } + + const { body: multipartBody, contentType: multipartCT } = await buildMultipartBody(file, { + model: modelId as string, + ...extraFields, + }); + + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { ...buildAuthHeaders(providerConfig, token), "Content-Type": multipartCT }, + body: multipartBody, + }); + + if (!res.ok) { + const errText = await res.text(); + return errorResponse(res.status, extractUpstreamErrorMessage(errText, res.status)); + } + + const data = await res.text(); + const respContentType = res.headers.get("content-type") || "application/json"; + + return new Response(data, { + status: 200, + headers: { "Content-Type": respContentType }, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return errorResponse(500, `Translation request failed: ${error.message}`); + } +} diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4535402de7..45a58c3f57 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -5,6 +5,10 @@ import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; +import { + shouldDefaultAllowClassifier, + buildDefaultAllowClaudeMessage, +} from "./chatCore/claudeClassifierCompat.ts"; import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts"; import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts"; import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts"; @@ -22,6 +26,7 @@ import { isStripReasoningRequested, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; +import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts"; import { getCombosCached } from "./chatCore/comboContextCache.ts"; export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts"; import { @@ -212,6 +217,7 @@ import { type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts"; import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { createBodyTimeoutError, @@ -246,6 +252,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts"; import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; +import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { sanitizeOpenAIResponse, @@ -454,6 +461,15 @@ export async function handleChatCore({ if (pluginGate.body) { body = pluginGate.body; } + // Per-API-key device/connection tracking (port of upstream 9router#931, + // thanks @mugnimaestra). In-memory only, never blocks the request path. + if (apiKeyInfo?.id) { + trackDevice( + apiKeyInfo.id, + extractIpFromHeaders(clientRawRequest?.headers ?? null), + userAgent ?? null + ); + } const agentGoalPolicy = resolveAgentGoalPolicy(body, clientRawRequest?.headers ?? null); if (agentGoalPolicy.detected) { log?.debug?.( @@ -577,6 +593,30 @@ export async function handleChatCore({ return bypassResponse; } + // ── Claude Code auto-mode classifier compat (opt-in, default "off") ── + // Claude Code's `--permission-mode auto` sends an internal classifier request that + // requires the response to START with `no`/`yes`. + // When a combo/fallback route sends that call to a cheap model returning 200 with + // empty content, Claude Code fails closed on every gated action. Detect the + // classifier request and short-circuit with a synthetic ALLOW response, WITHOUT + // calling the upstream provider. See chatCore/claudeClassifierCompat.ts. + { + const classifierSettings = cachedSettings ?? (await getCachedSettings()); + if ( + shouldDefaultAllowClassifier( + sourceFormat, + body as Record, + classifierSettings.claudeClassifierCompat as string | undefined + ) + ) { + log?.warn?.( + "CHAT", + `classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow` + ); + return buildDefaultAllowClaudeMessage(requestedModel); + } + } + // Detect source format and get target format // Model-specific targetFormat takes priority over provider default @@ -3517,6 +3557,64 @@ export async function handleChatCore({ let responseBody = parsed.responseBody; let responsePayloadFormat = parsed.responsePayloadFormat; + // ── ClinePass {success,data} envelope unwrap (before translation) ────────── + // ClinePass wraps non-streaming JSON in a {success, data} envelope; errors + // use {success:false, error}. Transient {success:false, error:"empty..."} + // responses get one 2s retry before surfacing. CLINEPASS-GATED — untouched + // for every other provider. Envelope errors route through createErrorResult + // (→ buildErrorBody/sanitizeErrorMessage, Rule #12). + if (provider === "clinepass") { + let { body: unwrapped, error: envError } = unwrapClinepassEnvelope(responseBody, provider); + if (envError && /empty/i.test(envError.message || "")) { + log?.warn?.("RETRY", "clinepass returned empty content, retrying once after 2s"); + await new Promise((r) => setTimeout(r, 2000)); + try { + const retryResult = await executeProviderRequest(effectiveModel, false); + if (retryResult?.response?.ok) { + const retryParsed = await parseNonStreamingResponseBody({ + providerResponse: retryResult.response, + upstreamStream: undefined, + providerHeaders: retryResult.headers, + finalBody: retryResult.transformedBody, + targetFormat, + model, + log, + }); + if (retryParsed.kind !== "invalid_sse" && retryParsed.kind !== "invalid_json") { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerHeaders = retryResult.headers; + finalBody = providerRequestCapture.body(retryResult.transformedBody); + ({ body: unwrapped, error: envError } = unwrapClinepassEnvelope( + retryParsed.responseBody, + provider + )); + } + } + } catch (retryErr) { + log?.warn?.( + "RETRY", + `clinepass retry failed: ${ + retryErr instanceof Error ? retryErr.message : String(retryErr) + }` + ); + } + } + if (envError) { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message); + } + responseBody = unwrapped; + } + responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody); + // Check for empty content response (fake success) - trigger fallback if (isEmptyContentResponse(responseBody)) { appendRequestLog({ diff --git a/open-sse/handlers/chatCore/claudeClassifierCompat.ts b/open-sse/handlers/chatCore/claudeClassifierCompat.ts new file mode 100644 index 0000000000..c524a7a9eb --- /dev/null +++ b/open-sse/handlers/chatCore/claudeClassifierCompat.ts @@ -0,0 +1,99 @@ +/** + * Claude Code auto-mode classifier compat mode (opt-in, default "off"). + * + * Claude Code's `--permission-mode auto` sends an internal `/v1/messages` + * security-classifier request and requires the response to START with the literal + * token `no` (ALLOW) or `yes` (BLOCK) — anything else + * is unparseable and Claude Code fails closed with "Auto mode could not evaluate + * this action and is blocking it for safety". + * + * When a combo/fallback route sends the classifier call to a cheap model that + * returns 200 with empty content, the well-formed-but-empty Claude message + * OmniRoute would normally produce still fails that parser — every gated action + * (WebFetch, Bash, Edit, …) ends up fail-closed. With `claudeClassifierCompat` set + * to "auto" or "always", handleChatCore detects the classifier request up front + * and short-circuits with a synthetic ALLOW response, WITHOUT ever calling the + * upstream provider. Default is "off": nothing changes unless an operator + * explicitly opts in (never mutates legitimate traffic by default). + */ + +import { FORMATS } from "../../translator/formats.ts"; + +/** The literal system-prompt marker Claude Code's classifier request carries. */ +const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI coding agents"; + +export type ClaudeClassifierCompatMode = "off" | "auto" | "always"; + +function extractSystemTexts(body: Record | null | undefined): string[] { + const system = body?.system; + if (typeof system === "string") return [system]; + if (Array.isArray(system)) { + return system + .map((part) => (part && typeof (part as { text?: unknown }).text === "string" + ? ((part as { text: string }).text) + : "")) + .filter(Boolean); + } + return []; +} + +/** + * True when the inbound request should be default-allowed without calling upstream. + * + * - `mode === "off"` (default): never short-circuits. + * - `mode === "always"`: short-circuits every Claude-format request (operator has + * decided every `/v1/messages` call through this route is the classifier). + * - `mode === "auto"`: only short-circuits when the request carries the classifier's + * system-prompt marker OR lists `` as a stop sequence — the two + * independent signals Claude Code's own classifier request relies on. + */ +export function shouldDefaultAllowClassifier( + sourceFormat: string, + body: Record | null | undefined, + mode: ClaudeClassifierCompatMode | string | null | undefined +): boolean { + if (mode !== "auto" && mode !== "always") return false; + if (sourceFormat !== FORMATS.CLAUDE) return false; + if (mode === "always") return true; + + const stopSequences = Array.isArray(body?.stop_sequences) + ? (body!.stop_sequences as unknown[]) + : []; + if (stopSequences.includes("")) return true; + + return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER)); +} + +/** + * Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON + * body (matching the upstream reference implementation) — Claude Code's classifier + * reads the assistant text content, not an SSE stream, so a single JSON response + * satisfies both streaming and non-streaming callers without needing to plumb a + * synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers. + */ +export function buildDefaultAllowClaudeMessage(model?: string | null): { + success: true; + response: Response; +} { + const message = { + id: `msg_${globalThis.crypto.randomUUID()}`, + type: "message", + role: "assistant", + model: model || "claude-3-5-sonnet-20241022", + content: [{ type: "text", text: "no" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + return { + success: true, + response: new Response(JSON.stringify(message), { + status: 200, + headers: { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + }, + }), + }; +} diff --git a/open-sse/handlers/chatCore/clineResponseEnvelope.ts b/open-sse/handlers/chatCore/clineResponseEnvelope.ts new file mode 100644 index 0000000000..0882ef3718 --- /dev/null +++ b/open-sse/handlers/chatCore/clineResponseEnvelope.ts @@ -0,0 +1,25 @@ +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasOpenAIChoices(value: unknown): boolean { + return isRecord(value) && Array.isArray(value.choices); +} + +export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown { + if (provider !== "cline" || !isRecord(responseBody)) { + return responseBody; + } + + const data = responseBody.data; + if (!hasOpenAIChoices(data)) { + return responseBody; + } + + return { + ...data, + usage: isRecord(data) && data.usage !== undefined ? data.usage : responseBody.usage, + }; +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 1e63e105bd..2bfc452a0a 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -50,6 +50,7 @@ import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts // are still used by handleImageEdit below, so they are imported (not re-defined). import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; +import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts"; import { handleComfyUIImageGeneration } from "./imageGeneration/providers/comfyUI.ts"; import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen3.ts"; import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts"; @@ -60,6 +61,7 @@ import { extractMarkdownImageUrls, CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; +import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; interface KieImageOptions { @@ -379,6 +381,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "huggingface-image") { + return handleHuggingFaceImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "fal-ai") { return handleFalAIImageGeneration({ model, @@ -511,6 +524,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "nvidia-nim") { + return handleNvidiaNimImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } diff --git a/open-sse/handlers/imageGeneration/providers/huggingface.ts b/open-sse/handlers/imageGeneration/providers/huggingface.ts new file mode 100644 index 0000000000..7e5cad8de6 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/huggingface.ts @@ -0,0 +1,90 @@ +// HuggingFace Hub image-generation provider. +// +// The HF Inference API text-to-image task returns the generated image as raw +// binary bytes (e.g. `image/jpeg`), not a JSON envelope — unlike most other +// image providers wired in this file. Mirrors the shape/error-handling +// conventions used by ./hyperbolic.ts and ./leonardo.ts. + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleHuggingFaceImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (log) { + log.info("IMAGE", `${provider}/${model} (huggingface) | prompt: "${prompt.slice(0, 60)}..."`); + } + + try { + const response = await fetch(`${providerConfig.baseUrl}/${model}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ inputs: prompt }), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const buf = await response.arrayBuffer(); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64"), revised_prompt: prompt }], + }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/nvidiaNim.ts b/open-sse/handlers/imageGeneration/providers/nvidiaNim.ts new file mode 100644 index 0000000000..863c20667d --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/nvidiaNim.ts @@ -0,0 +1,286 @@ +// NVIDIA NIM image generation (FLUX models) — ported from upstream 9router#1195. +// Unlike the NVIDIA *chat* entry (open-sse/config/providers/registry/nvidia/index.ts, +// host integrate.api.nvidia.com, OpenAI-compatible), NVIDIA NIM *image* generation lives +// on a different host (ai.api.nvidia.com) with a native per-model NIM body — so it gets +// its own provider handler rather than reusing the OpenAI-compatible image path. +// +// Invoke shape: POST https://ai.api.nvidia.com/v1/genai/ +// Authorization: Bearer +// where is the registered model id itself (e.g. "black-forest-labs/flux.1-dev"). +// +// Response shape varies across the NIM `genai` catalog (SDXL-style `artifacts[].base64`, +// list-of-strings `images[]`, OpenAI-style `data[].b64_json`, or single-value shorthands) +// — normalizeNvidiaNimImages() accepts every variant so a NIM response-shape change +// degrades to "no images" rather than throwing. + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +const FLUX_1_DEV = "black-forest-labs/flux.1-dev"; +const FLUX_1_KONTEXT_DEV = "black-forest-labs/flux.1-kontext-dev"; + +function numberFromInput(value: unknown): number | null { + if (value === undefined || value === null || value === "") return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function parseSizeString(size: unknown): { width: number; height: number } | null { + if (typeof size !== "string" || !size || size === "auto") return null; + const match = /^(\d+)x(\d+)$/.exec(size); + if (!match) return null; + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function parseDimensions(body: Record): { width: number; height: number } | null { + const width = numberFromInput(body.width); + const height = numberFromInput(body.height); + if (width !== null && height !== null) return { width, height }; + return parseSizeString(body.size); +} + +function copyIfPresent( + target: Record, + source: Record, + key: string +): void { + if (source[key] !== undefined && source[key] !== null && source[key] !== "") { + target[key] = source[key]; + } +} + +function copyNumberIfPresent( + target: Record, + source: Record, + key: string, + options: { greaterThan?: number } = {} +): void { + if (source[key] === undefined || source[key] === null || source[key] === "") return; + const value = Number(source[key]); + if (!Number.isFinite(value)) return; + if (options.greaterThan !== undefined && !(value > options.greaterThan)) return; + target[key] = value; +} + +function normalizeImageArray(image: unknown): unknown[] { + if (Array.isArray(image)) return image.filter(Boolean); + return image ? [image] : []; +} + +// FLUX.1 Dev only accepts dimensions in the 768-1344px range, in 64px increments — +// out-of-range values are silently dropped rather than sent upstream (matches upstream +// 9router#1195 behavior, verified against build.nvidia.com model page constraints). +function isFlux1DevDimension(value: number): boolean { + return Number.isInteger(value) && value >= 768 && value <= 1344 && value % 64 === 0; +} + +/** + * Build the per-model NIM request body. Each FLUX family member on NIM accepts a + * slightly different parameter set: + * - flux.1-dev: mode (base/depth/canny) + cfg_scale (only forwarded if > 1) + strict + * 768-1344/64px-increment width/height validation; input image only sent for + * non-"base" modes (depth/canny control image) + * - flux.1-kontext-dev: image-conditioned edit — requires `image`, uses `aspect_ratio` + * instead of width/height (the model preserves/derives its own output dimensions) + * - flux.1-schnell / flux.2-klein-4b: width/height/seed/steps, optional `image` sent as + * an array when present (edit-style input) + */ +export function buildNvidiaNimRequestBody( + model: string, + body: Record +): Record { + const req: Record = { prompt: body.prompt }; + const dimensions = parseDimensions(body); + + if (dimensions && model !== FLUX_1_KONTEXT_DEV) { + if (model !== FLUX_1_DEV || (isFlux1DevDimension(dimensions.width) && isFlux1DevDimension(dimensions.height))) { + req.width = dimensions.width; + req.height = dimensions.height; + } + } + + if (model === FLUX_1_DEV) { + const mode = body.mode || "base"; + req.mode = mode; + if (mode !== "base") { + const images = normalizeImageArray(body.image); + if (images.length > 0) req.image = images[0]; + } + } else if (model === FLUX_1_KONTEXT_DEV) { + const images = normalizeImageArray(body.image); + if (images.length > 0) req.image = images[0]; + copyIfPresent(req, body, "aspect_ratio"); + } else if (body.image) { + req.image = normalizeImageArray(body.image); + } + + if (model === FLUX_1_DEV) { + copyNumberIfPresent(req, body, "cfg_scale", { greaterThan: 1 }); + } else { + copyIfPresent(req, body, "cfg_scale"); + } + copyIfPresent(req, body, "seed"); + copyIfPresent(req, body, "steps"); + return req; +} + +function imageItemFromValue(value: unknown): { b64_json?: string; url?: string; finish_reason?: string } | null { + if (!value) return null; + if (typeof value === "string") return { b64_json: value }; + if (typeof value !== "object") return null; + const obj = value as Record; + if (typeof obj.url === "string") return { url: obj.url }; + const base64 = obj.base64 || obj.b64_json || obj.image || obj.data; + if (typeof base64 !== "string") return null; + const item: { b64_json: string; finish_reason?: string } = { b64_json: base64 }; + const finishReason = obj.finishReason || obj.finish_reason; + if (typeof finishReason === "string") item.finish_reason = finishReason; + return item; +} + +/** + * Normalize the NIM response into `{ created, data: [{ b64_json | url, finish_reason? }] }`. + * Accepts every response shape seen across the NIM `genai` catalog rather than a single + * assumed format. + */ +export function normalizeNvidiaNimImages(responseBody: unknown): { + created: number; + data: Array<{ b64_json?: string; url?: string; finish_reason?: string }>; +} { + const obj = (responseBody && typeof responseBody === "object" ? responseBody : {}) as Record< + string, + unknown + >; + + // Already OpenAI-shaped — pass through. + if (typeof obj.created === "number" && Array.isArray(obj.data)) { + return obj as { created: number; data: Array<{ b64_json?: string; url?: string }> }; + } + + const candidates: unknown[] = []; + if (Array.isArray(obj.artifacts)) candidates.push(...obj.artifacts); + if (Array.isArray(obj.images)) candidates.push(...obj.images); + if (Array.isArray(obj.data)) candidates.push(...obj.data); + if (obj.artifact) candidates.push(obj.artifact); + if (obj.image) candidates.push(obj.image); + if (obj.base64) candidates.push(obj.base64); + const result = obj.result as Record | undefined; + if (result?.image) candidates.push(result.image); + if (result && Array.isArray(result.artifacts)) candidates.push(...result.artifacts); + + return { + created: Math.floor(Date.now() / 1000), + data: candidates.map(imageItemFromValue).filter((item): item is NonNullable => item !== null), + }; +} + +export async function handleNvidiaNimImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || ""; + + if (model === FLUX_1_KONTEXT_DEV && !body.image) { + return { + success: false, + status: 400, + error: "NVIDIA FLUX.1 Kontext Dev requires an input image", + }; + } + + const requestBody = buildNvidiaNimRequestBody(model, body); + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (nvidia-nim) | prompt: "${promptPreview}..."`); + } + + const url = `${providerConfig.baseUrl.replace(/\/$/, "")}/${model}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: response.status, error: errorText }; + } + + const payload = await response.json(); + const normalized = normalizeNvidiaNimImages(payload); + + if (normalized.data.length === 0) { + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "No images returned from NVIDIA NIM", + }).catch(() => {}); + return { success: false, status: 502, error: "No images returned from NVIDIA NIM" }; + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: normalized.data.length }, + }).catch(() => {}); + + return { success: true, data: normalized }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${(err as Error).message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: (err as Error).message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts new file mode 100644 index 0000000000..bf0c553ff0 --- /dev/null +++ b/open-sse/handlers/ocr.ts @@ -0,0 +1,79 @@ +import { CORS_HEADERS } from "../utils/cors.ts"; +/** + * OCR Handler + * + * Handles POST /v1/ocr (Mistral OCR API format). + */ + +import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts"; +import { errorResponse } from "../utils/error.ts"; +import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; +import { generateRequestId } from "@/shared/utils/requestId"; + +/** + * Handle OCR request + * + * @param {Object} options + * @param {Object} options.body - JSON body { model, document } + * @param {Object} options.credentials - Provider credentials { apiKey } + * @returns {Response} + */ +/** @returns {Promise} */ +export async function handleOcr({ body, credentials }) { + const startTime = Date.now(); + if (!body.document) { + return errorResponse(400, "document is required"); + } + + // Default to latest OCR model + const model = body.model || "mistral-ocr-latest"; + const { provider: providerId, model: modelId } = parseOcrModel(model); + const providerConfig = providerId ? getOcrProvider(providerId) : null; + + if (!providerConfig) { + return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`); + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return errorResponse(401, `No credentials for OCR provider: ${providerId}`); + } + + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ...body, + model: modelId, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + }, + }); + } + + const data = await res.json(); + const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" }); + attachOmniRouteMetaHeaders(headers, { + provider: providerId, + model: modelId, + costUsd: 0, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + }); + return new Response(JSON.stringify(data), { status: 200, headers }); + } catch (err) { + return errorResponse(500, `OCR request failed: ${err.message}`); + } +} diff --git a/open-sse/handlers/responseSanitizer/reasoning.ts b/open-sse/handlers/responseSanitizer/reasoning.ts index cf15fc2fc6..867d455c84 100644 --- a/open-sse/handlers/responseSanitizer/reasoning.ts +++ b/open-sse/handlers/responseSanitizer/reasoning.ts @@ -129,7 +129,13 @@ export function isTextualReasoningTagNativeRoute(providerId: string, modelId: st return ( /deepseek[-_/]?r1\b/.test(routeId) || /r1[-_/]?distill\b/.test(routeId) || - /(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId) + /(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId) || + // 9router#2231: MiniMax M3 leaks raw ... into `content` on its + // OpenAI-format provider tiers (trae, huggingchat, bazaarlink, ollama-cloud, + // opencode, cline, opencode-zen, codebuddy-cn). The direct minimax/minimax-cn + // tiers stay on Anthropic's Messages format (targetFormat: "claude") and + // already surface reasoning natively, so they are excluded here. + (providerId !== "minimax" && providerId !== "minimax-cn" && /minimax[-_]?m3\b/.test(routeId)) ); } diff --git a/open-sse/mcp-server/audit.ts b/open-sse/mcp-server/audit.ts index 259ded1c49..40e023212c 100644 --- a/open-sse/mcp-server/audit.ts +++ b/open-sse/mcp-server/audit.ts @@ -206,6 +206,49 @@ function toString(value: unknown): string { return typeof value === "string" ? value : ""; } +async function openBetterSqliteAuditDb(dbPath: string): Promise { + const Database = (await import("better-sqlite3")).default as unknown as new ( + dbPath: string + ) => AuditDatabase; + return new Database(dbPath); +} + +function nodeSqliteFallbackAvailable(): boolean { + const [maj, min] = (process.versions.node ?? "0.0").split(".").map(Number); + return maj > 22 || (maj === 22 && (min ?? 0) >= 5); +} + +async function openNodeSqliteAuditDb(dbPath: string): Promise { + const { DatabaseSync } = (await import("node:sqlite")) as { + DatabaseSync: new (p: string) => NodeSqliteDatabase; + }; + return createNodeSqliteAuditAdapter(new DatabaseSync(dbPath)); +} + +async function openFallbackAuditDb(dbPath: string, nativeMessage: string): Promise { + if (!nodeSqliteFallbackAvailable()) { + console.error( + `[MCP Audit] better-sqlite3 native binding unavailable and Node ${process.version} ` + + "has no built-in sqlite. Audit logging disabled. Fix: run " + + "`npm rebuild better-sqlite3` in the omniroute install root." + ); + return null; + } + + try { + const adapter = await openNodeSqliteAuditDb(dbPath); + console.warn( + `[MCP Audit] better-sqlite3 binding unavailable — fell back to node:sqlite ` + + `(${nativeMessage.split("\n")[0]})` + ); + return adapter; + } catch (nodeErr) { + const nodeMessage = nodeErr instanceof Error ? nodeErr.message : String(nodeErr); + console.error("[MCP Audit] Failed to connect to database:", nodeMessage); + return null; + } +} + /** * Lazy-load the database connection. * Uses the same SQLite database as the main OmniRoute app. @@ -238,58 +281,19 @@ async function getDb(): Promise { return null; } - // Try better-sqlite3 first (matches the main app's default driver). try { - const Database = (await import("better-sqlite3")).default as unknown as new ( - dbPath: string - ) => AuditDatabase; - const database = new Database(dbPath); + const database = await openBetterSqliteAuditDb(dbPath); setCachedAuditDb(database); return database; } catch (nativeErr) { - // Declared once at the top of the catch: nativeMessage is read both on - // the non-fallback bail-out and in the node:sqlite fallback warning - // further down. A block-scoped const inside the `if` below would be out - // of scope in the fallback path. const nativeMessage = nativeErr instanceof Error ? nativeErr.message : String(nativeErr); - // Reuse the canonical detection helper from the main app's DB layer - // so we cover every ABI/binding failure mode the rest of the codebase - // already knows about: missing MODULE_NOT_FOUND, ERR_DLOPEN_FAILED, - // "Module did not self-register", "Cannot find module 'better-sqlite3'", - // the standard V8 "was compiled against a different Node.js version" - // message, and the bindings-loader "Could not locate the bindings file". - // Real errors (corrupt db, permission denied) still surface to the operator. if (!isNativeSqliteLoadError(nativeErr)) { console.error("[MCP Audit] Failed to connect to database:", nativeMessage); return null; } - // Fall back to Node's built-in sqlite (Node 22.5+). - const [maj, min] = (process.versions.node ?? "0.0").split(".").map(Number); - if (maj < 22 || (maj === 22 && (min ?? 0) < 5)) { - console.error( - `[MCP Audit] better-sqlite3 native binding unavailable and Node ${process.version} ` + - "has no built-in sqlite. Audit logging disabled. Fix: run " + - "`npm rebuild better-sqlite3` in the omniroute install root." - ); - return null; - } - try { - const { DatabaseSync } = (await import("node:sqlite")) as { - DatabaseSync: new (p: string) => NodeSqliteDatabase; - }; - const nodeDb = new DatabaseSync(dbPath); - const adapter = createNodeSqliteAuditAdapter(nodeDb); - setCachedAuditDb(adapter); - console.warn( - `[MCP Audit] better-sqlite3 binding unavailable — fell back to node:sqlite ` + - `(${nativeMessage.split("\n")[0]})` - ); - return adapter; - } catch (nodeErr) { - const nodeMessage = nodeErr instanceof Error ? nodeErr.message : String(nodeErr); - console.error("[MCP Audit] Failed to connect to database:", nodeMessage); - return null; - } + const fallbackDb = await openFallbackAuditDb(dbPath, nativeMessage); + setCachedAuditDb(fallbackDb); + return fallbackDb; } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/open-sse/mcp-server/catalog.ts b/open-sse/mcp-server/catalog.ts new file mode 100644 index 0000000000..d06a16f192 --- /dev/null +++ b/open-sse/mcp-server/catalog.ts @@ -0,0 +1,290 @@ +import { getCodexRequestDefaults } from "../../src/lib/providers/requestDefaults.ts"; +import { getProviderConnections } from "../../src/lib/db/providers.ts"; +import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; + +type JsonRecord = Record; +type McpCatalogStatus = "available" | "degraded" | "unavailable"; + +type McpCatalogResponse = { + models: Array<{ + id: string; + provider: string; + capabilities: string[]; + status: McpCatalogStatus; + thinkingEffort?: string; + pricing?: unknown; + }>; + source: string; + warning?: string; +}; + +type ProviderConnectionLike = { + id?: string; + provider?: string; + isActive?: boolean; + providerSpecificData?: unknown; +}; + +type McpCatalogRequestSpec = { + provider: string; + path: string; + thinkingEffort?: string; +}; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function toStringArray(value: unknown, fallback: string[] = []): string[] { + return Array.isArray(value) ? value.map((item) => String(item)) : fallback; +} + +function buildProviderAliasMap(): Record { + const aliasMap: Record = {}; + + for (const provider of Object.values(AI_PROVIDERS)) { + if (!provider?.id) continue; + aliasMap[provider.id] = provider.id; + if (typeof provider.alias === "string" && provider.alias.length > 0) { + aliasMap[provider.alias] = provider.id; + } + } + + for (const provider of Object.values(NOAUTH_PROVIDERS)) { + if (!provider?.id) continue; + aliasMap[provider.id] = provider.id; + if ("alias" in provider && typeof provider.alias === "string" && provider.alias.length > 0) { + aliasMap[provider.alias] = provider.id; + } + } + + return aliasMap; +} + +function normalizeCapability(value: string): string { + switch (value) { + case "embeddings": + return "embedding"; + case "images": + return "image"; + case "videos": + return "video"; + case "moderations": + return "moderation"; + case "chat-completions": + return "chat"; + default: + return value; + } +} + +function getCatalogModelCapabilities(model: JsonRecord): string[] { + if (Array.isArray(model.capabilities) && model.capabilities.length > 0) { + return toStringArray(model.capabilities, ["chat"]).map(normalizeCapability); + } + + if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) { + return toStringArray(model.supportedEndpoints, ["chat"]).map(normalizeCapability); + } + + const type = toString(model.type); + if (type) return [normalizeCapability(type)]; + + return ["chat"]; +} + +function normalizeCatalogStatus( + model: JsonRecord, + source: string, + warning?: string +): McpCatalogStatus { + const explicitStatus = toString(model.status); + if ( + explicitStatus === "available" || + explicitStatus === "degraded" || + explicitStatus === "unavailable" + ) { + return explicitStatus; + } + + if (warning || source === "local_catalog") return "degraded"; + return "available"; +} + +function getConnectionThinkingEffort(connection: ProviderConnectionLike): string | undefined { + const provider = typeof connection.provider === "string" ? connection.provider : null; + const providerSpecificData = toRecord(connection.providerSpecificData); + + if (provider === "codex") { + return getCodexRequestDefaults(providerSpecificData).reasoningEffort || "medium"; + } + + const rawThinkingEffort = toString(providerSpecificData.thinkingEffort); + return rawThinkingEffort || undefined; +} + +function normalizeProviderModelRecord( + rawModel: unknown, + fallbackProvider: string, + source: string, + warning?: string, + thinkingEffort?: string +) { + const model = toRecord(rawModel); + const id = toString(model.id, ""); + + return { + id, + provider: toString(model.owned_by, toString(model.provider, fallbackProvider)), + capabilities: getCatalogModelCapabilities(model), + status: normalizeCatalogStatus(model, source, warning), + ...(thinkingEffort ? { thinkingEffort } : {}), + pricing: model.pricing, + }; +} + +function activeProviderConnections( + connections: ProviderConnectionLike[], + normalizeProviderId: (value: string) => string, + requestedProvider: string | null +): ProviderConnectionLike[] { + return connections.filter((connection) => { + const provider = + typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; + return !!provider && !!connection?.id && connection.isActive !== false && + (!requestedProvider || provider === requestedProvider); + }); +} + +function providerModelRequestSpecs( + connections: ProviderConnectionLike[], + normalizeProviderId: (value: string) => string +): McpCatalogRequestSpec[] { + return connections.map((connection) => ({ + provider: normalizeProviderId(String(connection.provider)), + path: `/api/providers/${encodeURIComponent(String(connection.id))}/models?excludeHidden=true`, + thinkingEffort: getConnectionThinkingEffort(connection), + })); +} + +function noAuthProviderSpec(requestedProvider: string): McpCatalogRequestSpec { + return { + provider: requestedProvider, + path: `/api/v1/providers/${encodeURIComponent(requestedProvider)}/models`, + thinkingEffort: undefined, + }; +} + +function emptyCatalogForProvider(requestedProvider: string): McpCatalogResponse { + return { + models: [], + source: "provider_connections", + warning: `No active connections found for provider '${requestedProvider}'.`, + }; +} + +function rawModelsFromCatalog(raw: JsonRecord): unknown[] { + if (Array.isArray(raw.models)) return raw.models; + if (Array.isArray(raw.data)) return raw.data; + return []; +} + +function maybeCatalogModel( + rawModel: unknown, + spec: McpCatalogRequestSpec, + source: string, + warning: string | undefined, + requestedCapability: string | null +): McpCatalogResponse["models"][number] | null { + const normalized = normalizeProviderModelRecord(rawModel, spec.provider, source, warning); + if (spec.thinkingEffort && !normalized.thinkingEffort) normalized.thinkingEffort = spec.thinkingEffort; + if (!normalized.id) return null; + if (requestedCapability && !normalized.capabilities.includes(requestedCapability)) return null; + return normalized; +} + +function addCatalogModels( + raw: JsonRecord, + spec: McpCatalogRequestSpec, + source: string, + warning: string | undefined, + requestedCapability: string | null, + collectedModels: Map +) { + for (const rawModel of rawModelsFromCatalog(raw)) { + const normalized = maybeCatalogModel(rawModel, spec, source, warning, requestedCapability); + if (normalized) collectedModels.set(`${normalized.provider}:${normalized.id}`, normalized); + } +} + +async function collectCatalogModels( + requestSpecs: McpCatalogRequestSpec[], + fetchJson: (path: string) => Promise, + requestedCapability: string | null +) { + const collectedModels = new Map(); + const warnings = new Set(); + const sources = new Set(); + + for (const spec of requestSpecs) { + const raw = toRecord(await fetchJson(spec.path)); + const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog"); + const warning = raw.warning ? String(raw.warning) : undefined; + if (warning) warnings.add(warning); + sources.add(source); + addCatalogModels(raw, spec, source, warning, requestedCapability, collectedModels); + } + + return { collectedModels, warnings, sources }; +} + +export async function getMcpModelsCatalog( + args: { provider?: string; capability?: string }, + deps: { + fetchJson?: (path: string) => Promise; + listProviderConnections?: () => Promise; + } = {} +): Promise { + const fetchJson = deps.fetchJson ?? ((path: string) => import("./server.ts").then((m) => m.omniRouteFetch(path))); + const listProviderConnections = deps.listProviderConnections ?? getProviderConnections; + const aliasMap = buildProviderAliasMap(); + const normalizeProviderId = (value: string) => aliasMap[value] || value; + const requestedProvider = args.provider ? normalizeProviderId(args.provider) : null; + const requestedCapability = args.capability ? normalizeCapability(args.capability) : null; + + let connections = await listProviderConnections(); + connections = Array.isArray(connections) ? connections : []; + const activeConnections = activeProviderConnections( + connections, + normalizeProviderId, + requestedProvider + ); + const requestSpecs = providerModelRequestSpecs(activeConnections, normalizeProviderId); + + if (requestedProvider && requestSpecs.length === 0) { + const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( + (provider) => provider.id === requestedProvider + ); + if (isNoAuthProvider) { + requestSpecs.push(noAuthProviderSpec(requestedProvider)); + } else { + return emptyCatalogForProvider(requestedProvider); + } + } + + const { collectedModels, warnings, sources } = await collectCatalogModels( + requestSpecs, + fetchJson, + requestedCapability + ); + + return { + models: [...collectedModels.values()], + source: sources.size === 1 ? [...sources][0] : "aggregated_provider_models", + ...(warnings.size > 0 ? { warning: [...warnings].join(" | ") } : {}), + }; +} diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index 67448e15d0..7b982142d4 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -173,6 +173,27 @@ async function handleStreamableRequest(request: Request): Promise { // terminated/unknown, the server MUST respond with HTTP 404 Not Found so the // client re-initializes. A 400 here is non-recoverable for spec-compliant // clients (they only re-init on 404). See issue #5169. + // + // Auto-recovery: if the client sends an initialize request with a stale session + // id (e.g. after a server restart or idle eviction), treat it as a fresh + // initialization rather than hard-failing with 404. This avoids requiring users + // to manually restart their MCP client after every server restart. + if (await isInitializeRequest(request)) { + const newSession = createStreamableSession(); + try { + const response = await withMcpHttpAuthContext(request, () => + newSession.transport.handleRequest(request) + ); + return withSessionHeader(response, newSession.sessionId); + } catch (err) { + closeStreamableSession(newSession.sessionId); + console.error("[MCP] Streamable HTTP error during stale-session recovery:", err); + return new Response(JSON.stringify({ error: "MCP transport error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + } return errorResponse("Not Found: Unknown Mcp-Session-Id header", -32000, 404); } diff --git a/open-sse/mcp-server/schemas/pickFastestModel.ts b/open-sse/mcp-server/schemas/pickFastestModel.ts new file mode 100644 index 0000000000..3434b02ec6 --- /dev/null +++ b/open-sse/mcp-server/schemas/pickFastestModel.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; +import type { McpToolDefinition } from "./toolDefinition.ts"; + +export const pickFastestModelInput = z.object({ + comboId: z + .string() + .optional() + .describe( + "Optional combo id or name to scope the ranking to. Omit to rank across all enabled combos." + ), + includeUnhealthy: z + .boolean() + .optional() + .describe( + "When true, OPEN-circuit candidates are scored (sorted to the bottom) instead of filtered out." + ), + weights: z + .object({ + ttft: z.number().min(0).optional(), + tps: z.number().min(0).optional(), + e2e: z.number().min(0).optional(), + p95: z.number().min(0).optional(), + health: z.number().min(0).optional(), + reliability: z.number().min(0).optional(), + stability: z.number().min(0).optional(), + }) + .partial() + .optional() + .describe("Optional speed-ranking weight overrides merged onto the defaults."), + applyToCombo: z + .boolean() + .optional() + .describe("When true + comboId present, switches the combo to auto/latency routing."), + limit: z.number().int().min(1).max(50).optional().describe("Ranked result limit."), +}); + +export const pickFastestModelOutput = z.object({ + fastest: z + .object({ + provider: z.string(), + model: z.string(), + score: z.number(), + reason: z.string(), + }) + .nullable(), + ranked: z.array( + z.object({ + provider: z.string(), + model: z.string(), + score: z.number(), + factors: z.object({ + ttft: z.number(), + tps: z.number(), + e2e: z.number(), + p95: z.number(), + health: z.number(), + reliability: z.number(), + stability: z.number(), + }), + metrics: z.object({ + avgTtftMs: z.number().nullable(), + avgTokensPerSecond: z.number().nullable(), + avgE2ELatencyMs: z.number().nullable(), + p95LatencyMs: z.number().nullable(), + latencyStdDev: z.number().nullable(), + failureRate: z.number(), + circuitBreakerState: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + }), + reason: z.string(), + }) + ), + weights: z.object({ + ttft: z.number(), + tps: z.number(), + e2e: z.number(), + p95: z.number(), + health: z.number(), + reliability: z.number(), + stability: z.number(), + }), + comboScope: z.object({ id: z.string(), name: z.string() }).nullable(), + appliedToCombo: z + .object({ + id: z.string(), + name: z.string(), + strategy: z.string(), + autoRoutingStrategy: z.string(), + }) + .nullable(), +}); + +export const pickFastestModelTool: McpToolDefinition< + typeof pickFastestModelInput, + typeof pickFastestModelOutput +> = { + name: "omniroute_pick_fastest_model", + description: + "Picks the fastest reliable provider-model pair from live telemetry and can apply latency routing to a combo.", + inputSchema: pickFastestModelInput, + outputSchema: pickFastestModelOutput, + scopes: ["read:combos", "read:health", "read:usage"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: [ + "/api/combos", + "/api/monitoring/health", + "/api/usage/quota", + "/api/usage/analytics", + ], +}; diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index b7b0b84090..85b5c3edd7 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1,5 +1,5 @@ /** - * MCP Tool Schemas — Contracts for all 22 core and advanced OmniRoute MCP tools. + * MCP Tool Schemas — Contracts for all 23 core and advanced OmniRoute MCP tools. * * Defines input/output Zod schemas, descriptions, scopes, and audit levels * for both essential (Phase 1) and advanced (Phase 2) MCP tools. @@ -11,6 +11,7 @@ import { z } from "zod"; import { toolSearchTool } from "./toolSearch.ts"; +import { pickFastestModelTool } from "./pickFastestModel.ts"; import { AUTO_ROUTING_STRATEGY_VALUES, ROUTING_STRATEGY_VALUES, @@ -22,6 +23,7 @@ import { // Re-exported here for backward compatibility (many modules import them from ./tools.ts). export type { AuditLevel, McpToolDefinition } from "./toolDefinition.ts"; import type { McpToolDefinition } from "./toolDefinition.ts"; +export { pickFastestModelInput, pickFastestModelOutput } from "./pickFastestModel.ts"; // ============ Phase 1: Essential Tools (8) ============ @@ -1466,6 +1468,7 @@ export const MCP_TOOLS = [ agentSkillsListTool, agentSkillsGetTool, agentSkillsCoverageTool, + pickFastestModelTool, ] as const; export const MCP_ESSENTIAL_TOOLS = MCP_TOOLS.filter((t) => t.phase === 1); diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 554f1ab1d8..cbf95b1956 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -5,9 +5,7 @@ import { getComboModelString, getComboStepTarget, } from "../../src/lib/combos/steps.ts"; - import { registerToolSearchTool } from "./toolSearch/register.ts"; - import { MCP_TOOLS, getHealthInput, @@ -28,6 +26,7 @@ import { getProviderMetricsInput, bestComboForTaskInput, explainRouteInput, + pickFastestModelInput, getSessionSnapshotInput, dbHealthCheckInput, syncPricingInput, @@ -38,7 +37,6 @@ import { oneproxyStatsInput, } from "./schemas/tools.ts"; import { startMcpHeartbeat } from "./runtimeHeartbeat.ts"; - import { z } from "zod"; import { closeAuditDb, logToolCall } from "./audit.ts"; import { @@ -47,7 +45,6 @@ import { type McpToolExtraLike, } from "./scopeEnforcement.ts"; import { getMcpHttpAuthHeadersForInternalFetch } from "./httpAuthContext.ts"; - import { handleSimulateRoute, handleSetBudgetGuard, @@ -66,6 +63,7 @@ import { handleOneproxyRotate, handleOneproxyStats, } from "./tools/advancedTools.ts"; +import { handlePickFastestModel } from "./tools/pickFastestModel.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; import { agentSkillTools } from "./tools/agentSkillTools.ts"; @@ -86,11 +84,10 @@ import { type McpAccessibilityConfig, } from "../services/compression/engines/mcpAccessibility/constants.ts"; import { getDbInstance } from "../../src/lib/db/core.ts"; -import { getProviderConnections } from "../../src/lib/db/providers.ts"; -import { getCodexRequestDefaults } from "../../src/lib/providers/requestDefaults.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; -import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; +import { getMcpModelsCatalog } from "./catalog.ts"; +export { getMcpModelsCatalog } from "./catalog.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; @@ -144,28 +141,6 @@ type TextToolResult = { isError?: boolean; }; -type McpCatalogStatus = "available" | "degraded" | "unavailable"; - -type McpCatalogResponse = { - models: Array<{ - id: string; - provider: string; - capabilities: string[]; - status: McpCatalogStatus; - thinkingEffort?: string; - pricing?: unknown; - }>; - source: string; - warning?: string; -}; - -type ProviderConnectionLike = { - id?: string; - provider?: string; - isActive?: boolean; - providerSpecificData?: unknown; -}; - function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -210,7 +185,7 @@ function getOmniRouteApiKey(): string { return process.env.OMNIROUTE_API_KEY || ""; } -async function omniRouteFetch(path: string, options: RequestInit = {}): Promise { +export async function omniRouteFetch(path: string, options: RequestInit = {}): Promise { const url = `${OMNIROUTE_BASE_URL}${path}`; const apiKey = getOmniRouteApiKey(); const headers: Record = { @@ -233,202 +208,6 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< return response.json(); } -function buildProviderAliasMap(): Record { - const aliasMap: Record = {}; - - for (const provider of Object.values(AI_PROVIDERS)) { - if (!provider?.id) continue; - aliasMap[provider.id] = provider.id; - if (typeof provider.alias === "string" && provider.alias.length > 0) { - aliasMap[provider.alias] = provider.id; - } - } - - for (const provider of Object.values(NOAUTH_PROVIDERS)) { - if (!provider?.id) continue; - aliasMap[provider.id] = provider.id; - if ("alias" in provider && typeof provider.alias === "string" && provider.alias.length > 0) { - aliasMap[provider.alias] = provider.id; - } - } - - return aliasMap; -} - -function normalizeCapability(value: string): string { - switch (value) { - case "embeddings": - return "embedding"; - case "images": - return "image"; - case "videos": - return "video"; - case "moderations": - return "moderation"; - case "chat-completions": - return "chat"; - default: - return value; - } -} - -function getCatalogModelCapabilities(model: JsonRecord): string[] { - if (Array.isArray(model.capabilities) && model.capabilities.length > 0) { - return toStringArray(model.capabilities, ["chat"]).map(normalizeCapability); - } - - if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) { - return toStringArray(model.supportedEndpoints, ["chat"]).map(normalizeCapability); - } - - const type = toString(model.type); - if (type) return [normalizeCapability(type)]; - - return ["chat"]; -} - -function normalizeCatalogStatus( - model: JsonRecord, - source: string, - warning?: string -): McpCatalogStatus { - const explicitStatus = toString(model.status); - if ( - explicitStatus === "available" || - explicitStatus === "degraded" || - explicitStatus === "unavailable" - ) { - return explicitStatus; - } - - if (warning || source === "local_catalog") return "degraded"; - return "available"; -} - -function getConnectionThinkingEffort(connection: ProviderConnectionLike): string | undefined { - const provider = typeof connection.provider === "string" ? connection.provider : null; - const providerSpecificData = toRecord(connection.providerSpecificData); - - if (provider === "codex") { - return getCodexRequestDefaults(providerSpecificData).reasoningEffort || "medium"; - } - - const rawThinkingEffort = toString(providerSpecificData.thinkingEffort); - return rawThinkingEffort || undefined; -} - -function normalizeProviderModelRecord( - rawModel: unknown, - fallbackProvider: string, - source: string, - warning?: string, - thinkingEffort?: string -) { - const model = toRecord(rawModel); - const id = toString(model.id, ""); - - return { - id, - provider: toString(model.owned_by, toString(model.provider, fallbackProvider)), - capabilities: getCatalogModelCapabilities(model), - status: normalizeCatalogStatus(model, source, warning), - ...(thinkingEffort ? { thinkingEffort } : {}), - pricing: model.pricing, - }; -} - -export async function getMcpModelsCatalog( - args: { provider?: string; capability?: string }, - deps: { - fetchJson?: (path: string) => Promise; - listProviderConnections?: () => Promise; - } = {} -): Promise { - const fetchJson = deps.fetchJson ?? ((path: string) => omniRouteFetch(path)); - const listProviderConnections = deps.listProviderConnections ?? getProviderConnections; - const aliasMap = buildProviderAliasMap(); - const normalizeProviderId = (value: string) => aliasMap[value] || value; - const requestedProvider = args.provider ? normalizeProviderId(args.provider) : null; - const requestedCapability = args.capability ? normalizeCapability(args.capability) : null; - - let connections = await listProviderConnections(); - connections = Array.isArray(connections) ? connections : []; - - const activeConnections = connections.filter((connection) => { - const provider = - typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; - if (!provider || !connection?.id || connection.isActive === false) return false; - if (requestedProvider && provider !== requestedProvider) return false; - return true; - }); - - const requestSpecs = activeConnections.map((connection) => ({ - provider: normalizeProviderId(String(connection.provider)), - path: `/api/providers/${encodeURIComponent(String(connection.id))}/models?excludeHidden=true`, - thinkingEffort: getConnectionThinkingEffort(connection), - })); - - if (requestedProvider && requestSpecs.length === 0) { - const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( - (provider) => provider.id === requestedProvider - ); - if (isNoAuthProvider) { - requestSpecs.push({ - provider: requestedProvider, - path: `/api/v1/providers/${encodeURIComponent(requestedProvider)}/models`, - thinkingEffort: undefined, - }); - } else { - return { - models: [], - source: "provider_connections", - warning: `No active connections found for provider '${requestedProvider}'.`, - }; - } - } - - const collectedModels = new Map(); - const warnings = new Set(); - const sources = new Set(); - - for (const spec of requestSpecs) { - const raw = toRecord(await fetchJson(spec.path)); - const source = toString( - raw.source, - spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog" - ); - const warning = raw.warning ? String(raw.warning) : undefined; - if (warning) warnings.add(warning); - sources.add(source); - - const rawModels = Array.isArray(raw.models) - ? raw.models - : Array.isArray(raw.data) - ? raw.data - : []; - - for (const rawModel of rawModels) { - const normalized = normalizeProviderModelRecord(rawModel, spec.provider, source, warning); - if (spec.thinkingEffort && !normalized.thinkingEffort) { - normalized.thinkingEffort = spec.thinkingEffort; - } - if (!normalized.id) continue; - if (requestedCapability && !normalized.capabilities.includes(requestedCapability)) continue; - - const key = `${normalized.provider}:${normalized.id}`; - if (!collectedModels.has(key)) { - collectedModels.set(key, normalized); - } - } - } - - return { - models: [...collectedModels.values()], - source: sources.size === 1 ? [...sources][0] : "aggregated_provider_models", - ...(warnings.size > 0 ? { warning: [...warnings].join(" | ") } : {}), - }; -} - function withScopeEnforcement( toolName: string, handler: (args: unknown, extra?: McpToolExtraLike) => Promise, @@ -1085,6 +864,8 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool("omniroute_pick_fastest_model", { description: "Picks the fastest reliable provider-model pair from live telemetry.", inputSchema: pickFastestModelInput }, withScopeEnforcement("omniroute_pick_fastest_model", (args) => handlePickFastestModel(pickFastestModelInput.parse(args)))); + server.registerTool( "omniroute_get_session_snapshot", { diff --git a/open-sse/mcp-server/tools/pickFastestModel.ts b/open-sse/mcp-server/tools/pickFastestModel.ts new file mode 100644 index 0000000000..2d22ab9e15 --- /dev/null +++ b/open-sse/mcp-server/tools/pickFastestModel.ts @@ -0,0 +1,293 @@ +import { logToolCall } from "../audit.ts"; +import { getMcpHttpAuthHeadersForInternalFetch } from "../httpAuthContext.ts"; +import { normalizeQuotaResponse } from "../../../src/shared/contracts/quota.ts"; +import { resolveOmniRouteBaseUrl } from "../../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; +import { + getComboModelProvider, + getComboModelString, + getComboStepTarget, +} from "../../../src/lib/combos/steps.ts"; +import type { AutoRoutingStrategyValue } from "../../../src/shared/constants/routingStrategies.ts"; +import { rankBySpeed, DEFAULT_SPEED_WEIGHTS } from "../../services/autoCombo/speedRanking.ts"; +import type { SpeedCandidate } from "../../services/autoCombo/speedRanking.ts"; + +const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); +const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || ""; + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + const url = `${OMNIROUTE_BASE_URL}${path}`; + const headers: Record = { + "Content-Type": "application/json", + ...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}), + ...getMcpHttpAuthHeadersForInternalFetch(), + ...((options.headers as Record) || {}), + }; + const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(30000) }); + if (!response.ok) { + const text = await response.text().catch(() => "Unknown error"); + throw new Error(`API [${response.status}]: ${text}`); + } + return response.json(); +} + +type JsonRecord = Record; +interface ComboModel { provider: string; model: string; inputCostPer1M: number; } +interface PickFastestModelArgs { + comboId?: string; + /** When true, OPEN-circuit candidates are still scored (sorted to the bottom). */ + includeUnhealthy?: boolean; + /** Optional weight overrides; merged onto DEFAULT_SPEED_WEIGHTS. */ + weights?: Partial<{ + ttft: number; + tps: number; + e2e: number; + p95: number; + health: number; + reliability: number; + stability: number; + }>; + /** When true + comboId present, sets the combo's autoRoutingStrategy to "latency". */ + applyToCombo?: boolean; + /** Max number of ranked entries to return (default 10). */ + limit?: number; +} +interface TelemetrySources { + combos: JsonRecord[]; + breakers: JsonRecord[]; + providers: ReturnType["providers"]; + analyticsByProvider: JsonRecord; + analyticsTop: JsonRecord; +} + +function isRecord(value: unknown): value is JsonRecord { return !!value && typeof value === "object" && !Array.isArray(value); } +function toRecord(value: unknown): JsonRecord { return isRecord(value) ? value : {}; } +function toArrayOfRecords(value: unknown): JsonRecord[] { return Array.isArray(value) ? value.filter(isRecord) : []; } +function toString(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } +function toNumber(value: unknown, fallback = 0): number { + const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim().length > 0 ? Number(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : fallback; +} +function getComboModels(combo: JsonRecord): ComboModel[] { + const directModels = toArrayOfRecords(combo.models); + const nestedModels = toArrayOfRecords(toRecord(combo.data).models); + const sourceModels = directModels.length > 0 ? directModels : nestedModels; + return sourceModels.map((model) => ({ + provider: getComboModelProvider(model) || (getComboModelString(model) ? "unknown" : "combo"), + model: getComboModelString(model) || getComboStepTarget(model) || "", + inputCostPer1M: toNumber(model.inputCostPer1M, 3.0), + })); +} +function normalizeCombosResponse(raw: unknown): JsonRecord[] { + if (Array.isArray(raw)) return raw.filter(isRecord); + const source = toRecord(raw); + return Array.isArray(source.combos) ? source.combos.filter(isRecord) : []; +} + +function settledValue(result: PromiseSettledResult): unknown { + return result.status === "fulfilled" ? result.value : undefined; +} + +async function fetchTelemetrySources(): Promise { + const [combosRaw, healthRaw, quotaRaw, analyticsRaw] = await Promise.allSettled([ + apiFetch("/api/combos"), + apiFetch("/api/monitoring/health"), + apiFetch("/api/usage/quota"), + apiFetch("/api/usage/analytics?period=session"), + ]); + + const analytics = toRecord(settledValue(analyticsRaw)); + return { + combos: normalizeCombosResponse(settledValue(combosRaw)), + breakers: toArrayOfRecords(toRecord(settledValue(healthRaw)).circuitBreakers), + providers: normalizeQuotaResponse(settledValue(quotaRaw) ?? {}).providers, + analyticsByProvider: toRecord(toRecord(analytics.byProvider)), + analyticsTop: analytics, + }; +} + +function selectComboScope(combos: JsonRecord[], comboId?: string) { + const targetCombo = comboId + ? combos.find((combo) => toString(combo.id) === comboId || toString(combo.name) === comboId) + : undefined; + return { + targetCombo, + scopedCombos: targetCombo ? [targetCombo] : combos.filter((combo) => combo.enabled !== false), + }; +} + +function noCandidatesResult(error: string) { + return { content: [{ type: "text" as const, text: JSON.stringify({ error }) }], isError: true }; +} + +function providerAnalytics(sources: TelemetrySources, provider: string) { + const perProvider = toRecord(sources.analyticsByProvider[provider]); + return perProvider.requests + ? perProvider + : toRecord(sources.analyticsTop.byProvider && toRecord(sources.analyticsTop.byProvider)[provider]); +} + +function buildCandidate(model: ComboModel, sources: TelemetrySources): SpeedCandidate { + const cb = sources.breakers.find((breaker) => toString(breaker.provider) === model.provider); + const q = sources.providers.find((providerEntry) => providerEntry.provider === model.provider); + const analytics = providerAnalytics(sources, model.provider); + const cbState = toString(cb?.state, "CLOSED") as SpeedCandidate["circuitBreakerState"]; + const p95 = toNumber(analytics.p95LatencyMs, NaN); + const errorRate = toNumber(analytics.errorRate, 0); + + return { + provider: model.provider, + model: model.model, + circuitBreakerState: cbState, + avgE2ELatencyMs: toNumber(analytics.avgLatencyMs, NaN), + p95LatencyMs: Number.isFinite(p95) ? p95 : 0, + avgTokensPerSecond: toNumber(analytics.avgTokensPerSecond ?? analytics.tps, NaN), + avgTtftMs: toNumber(analytics.avgTtftMs ?? analytics.ttftMs, NaN), + latencyStdDev: toNumber(analytics.latencyStdDev, NaN), + errorRate: Number.isFinite(errorRate) ? errorRate : 0, + failureRate: Number.isFinite(errorRate) ? errorRate : 0, + quotaRemaining: q?.quotaUsed != null && q?.quotaTotal + ? Math.max(0, 100 - q.quotaUsed / q.quotaTotal * 100) + : 100, + quotaTotal: q?.quotaTotal ?? 100, + costPer1MTokens: model.inputCostPer1M ?? 0, + }; +} + +function buildSpeedCandidates(scopedCombos: JsonRecord[], sources: TelemetrySources): SpeedCandidate[] { + const speedCandidates: SpeedCandidate[] = []; + for (const combo of scopedCombos) { + for (const model of getComboModels(combo)) { + if (model.provider && model.model) speedCandidates.push(buildCandidate(model, sources)); + } + } + return speedCandidates; +} + +function candidateCompletenessScore(candidate: SpeedCandidate): number { + return (candidate.circuitBreakerState ? 1 : 0) + (candidate.quotaRemaining != null ? 1 : 0); +} + +function dedupeCandidates(candidates: SpeedCandidate[]): SpeedCandidate[] { + const deduped = new Map(); + for (const candidate of candidates) { + const key = `${candidate.provider}::${candidate.model}`; + const existing = deduped.get(key); + if (!existing || candidateCompletenessScore(candidate) > candidateCompletenessScore(existing)) { + deduped.set(key, candidate); + } + } + return [...deduped.values()]; +} + +async function applyWinnerToCombo(targetCombo: JsonRecord, winner: { provider: string; model: string }) { + const comboId = toString(targetCombo.id); + const comboData = toRecord(targetCombo.data); + const baseConfig = toRecord(targetCombo.config); + const currentConfig = Object.keys(baseConfig).length > 0 ? baseConfig : toRecord(comboData.config); + const nextConfig = { + ...currentConfig, + auto: { + ...toRecord(currentConfig.auto), + routerStrategy: "latency" as AutoRoutingStrategyValue, + }, + }; + const updatedCombo = toRecord( + await apiFetch(`/api/combos/${encodeURIComponent(comboId)}`, { + method: "PUT", + body: JSON.stringify({ strategy: "auto", config: nextConfig }), + }) + ); + const updatedConfig = toRecord(updatedCombo.config); + return { + id: toString(updatedCombo.id, comboId), + name: toString(updatedCombo.name, toString(targetCombo.name, comboId)), + strategy: toString(updatedCombo.strategy, "auto"), + autoRoutingStrategy: toString(toRecord(updatedConfig.auto).routerStrategy, "latency"), + }; +} + +/** + * Speed-optimized "pick the fastest reliable provider×model" tool. + * + * Composes live telemetry from `/api/combos/metrics` (per-combo per-model + * avg latency + success rate), `/api/monitoring/health` (circuit-breaker + * state), `/api/usage/quota` (quota remaining) and `/api/usage/analytics` + * (per-provider p95 / errorRate) into SpeedCandidates, runs the same + * `rankBySpeed` engine that drives `LatencyStrategyImpl` and the latency- + * optimized playground preview, and returns: + * - the top-ranked (fastest) provider×model pair, + * - the full ranked list with per-factor scores (ttft / tps / e2e / + * p95 / health / reliability / stability) so callers can show + * "why this one wins" in dashboards, + * - optionally applies the choice to a target combo by flipping its + * strategy to "auto" + autoRoutingStrategy "latency", so the runtime + * router will keep using this ranking. + */ +export async function handlePickFastestModel(args: PickFastestModelArgs) { + const start = Date.now(); + try { + const sources = await fetchTelemetrySources(); + const { targetCombo, scopedCombos } = selectComboScope(sources.combos, args.comboId); + + if (scopedCombos.length === 0) { + return noCandidatesResult("No matching combos available"); + } + + const finalCandidates = dedupeCandidates(buildSpeedCandidates(scopedCombos, sources)); + if (finalCandidates.length === 0) { + return noCandidatesResult("No provider×model candidates available to rank"); + } + + const weights = args.weights ? { ...DEFAULT_SPEED_WEIGHTS, ...args.weights } : DEFAULT_SPEED_WEIGHTS; + const ranked = rankBySpeed(finalCandidates, weights, { + includeUnhealthy: args.includeUnhealthy === true, + }); + + const limit = Math.min(Math.max(toNumber(args.limit, 10), 1), 50); + const trimmed = ranked.slice(0, limit); + const winner = trimmed[0]; + + let appliedToCombo: JsonRecord | null = null; + if (args.applyToCombo && targetCombo && winner) { + appliedToCombo = await applyWinnerToCombo(targetCombo, winner); + } + + const result = { + fastest: winner + ? { + provider: winner.provider, + model: winner.model, + score: winner.score, + reason: winner.reason, + } + : null, + ranked: trimmed.map((entry) => ({ + provider: entry.provider, + model: entry.model, + score: entry.score, + factors: entry.factors, + metrics: entry.metrics, + reason: entry.reason, + })), + weights, + comboScope: targetCombo + ? { id: toString(targetCombo.id), name: toString(targetCombo.name) } + : null, + appliedToCombo, + }; + + await logToolCall("omniroute_pick_fastest_model", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall( + "omniroute_pick_fastest_model", + args, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} diff --git a/open-sse/package.json b/open-sse/package.json index 1b03c6aeaf..f4e9ce4697 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.43", + "version": "3.8.44", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 0efddc1762..1ade866731 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -33,6 +33,7 @@ import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/provid import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; +import { parseRetryHintFromJsonBody } from "./retryAfterJson.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -1035,22 +1036,15 @@ function parseDelayString(value: unknown): number | null { return Number.isNaN(num) ? null : num * 1000; } -/** - * T07: Parse retry time from error text body with combined "XhYmZs" format. - * Examples: "Your quota will reset after 2h30m14s", "reset after 45m", "reset after 30s" - * Returns milliseconds or null if not parseable. - * - * @param {string} errorText - Error message text from response body - * @returns {number|null} Retry duration in milliseconds - */ +// T07: parse retry time from error text body with combined "XhYmZs" format. export function parseRetryFromErrorText(errorText: unknown): number | null { if (!errorText || typeof errorText !== "string") return null; const msg: string = String(errorText); - // Issue #2321: Anthropic OAuth occasionally embeds an absolute ISO 8601 - // timestamp instead of a relative duration (e.g. "Try again at - // 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z"). - // Convert to a future-duration in milliseconds if it parses. + const bodyHintMs = parseRetryHintFromJsonBody(msg, MAX_PROVIDER_COOLDOWN_MS); + if (bodyHintMs !== null) return bodyHintMs; + + // Issue #2321: parse embedded absolute ISO retry timestamps. const isoMatch = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec( msg diff --git a/open-sse/services/autoCombo/__tests__/speedRanking.test.ts b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts new file mode 100644 index 0000000000..1599e36b82 --- /dev/null +++ b/open-sse/services/autoCombo/__tests__/speedRanking.test.ts @@ -0,0 +1,226 @@ +/** + * Unit tests for the speed-optimized provider×model ranking engine. + * + * Covers the pure `rankBySpeed` function used by: + * - the runtime `LatencyStrategyImpl` (routerStrategy.ts) + * - the `omniroute_pick_fastest_model` MCP tool + * - the latency-optimized playground preview (via the same shared core) + */ + +import { describe, it, expect } from "vitest"; +import { + rankBySpeed, + pickFastest, + DEFAULT_SPEED_WEIGHTS, +} from "../speedRanking"; +import type { SpeedCandidate } from "../speedRanking"; + +function candidate(overrides: Partial = {}): SpeedCandidate { + return { + provider: "anthropic", + model: "claude-sonnet", + circuitBreakerState: "CLOSED", + errorRate: 0, + failureRate: 0, + quotaRemaining: 100, + quotaTotal: 100, + costPer1MTokens: 3, + p95LatencyMs: 1000, + latencyStdDev: 100, + ...overrides, + }; +} + +describe("rankBySpeed — selection", () => { + it("returns an empty list when the pool is empty", () => { + expect(rankBySpeed([])).toEqual([]); + }); + + it("filters out OPEN circuit-breaker candidates by default", () => { + const pool: SpeedCandidate[] = [ + candidate({ provider: "broken", model: "x", circuitBreakerState: "OPEN" }), + candidate({ provider: "ok", model: "y", avgTtftMs: 200 }), + ]; + const ranked = rankBySpeed(pool); + expect(ranked).toHaveLength(1); + expect(ranked[0].provider).toBe("ok"); + }); + + it("keeps OPEN candidates when includeUnhealthy is set, sorted to the bottom", () => { + const pool: SpeedCandidate[] = [ + candidate({ provider: "broken", model: "x", circuitBreakerState: "OPEN" }), + candidate({ provider: "ok", model: "y", avgTtftMs: 200, avgE2ELatencyMs: 1000 }), + ]; + const ranked = rankBySpeed(pool, DEFAULT_SPEED_WEIGHTS, { includeUnhealthy: true }); + expect(ranked).toHaveLength(2); + expect(ranked[0].provider).toBe("ok"); + expect(ranked[1].provider).toBe("broken"); + }); +}); + +describe("rankBySpeed — metric weighting", () => { + it("picks the lower-TTFT provider×model when TTFT dominates", () => { + const fast: SpeedCandidate = candidate({ + provider: "fast", + model: "m", + avgTtftMs: 120, + avgE2ELatencyMs: 1000, + avgTokensPerSecond: 80, + p95LatencyMs: 1100, + }); + const slow: SpeedCandidate = candidate({ + provider: "slow", + model: "m", + avgTtftMs: 900, + avgE2ELatencyMs: 6000, + avgTokensPerSecond: 20, + p95LatencyMs: 7000, + }); + const ranked = rankBySpeed([slow, fast]); + expect(ranked[0].provider).toBe("fast"); + }); + + it("penalizes high failure rate so a flaky fast provider loses to a steady slower one", () => { + const flakyFast: SpeedCandidate = candidate({ + provider: "flaky", + model: "m", + avgTtftMs: 250, + avgE2ELatencyMs: 1500, + avgTokensPerSecond: 90, + p95LatencyMs: 1700, + errorRate: 0.3, + failureRate: 0.3, + }); + const steadySlow: SpeedCandidate = candidate({ + provider: "steady", + model: "m", + avgTtftMs: 400, + avgE2ELatencyMs: 2200, + avgTokensPerSecond: 70, + p95LatencyMs: 2500, + errorRate: 0.01, + failureRate: 0.01, + }); + const ranked = rankBySpeed([flakyFast, steadySlow]); + expect(ranked[0].provider).toBe("steady"); + }); + + it("penalizes high latency stdDev so a bursty fast provider loses to a steady slow one", () => { + const bursty: SpeedCandidate = candidate({ + provider: "bursty", + model: "m", + avgTtftMs: 100, + avgE2ELatencyMs: 900, + avgTokensPerSecond: 100, + p95LatencyMs: 950, + latencyStdDev: 1500, + }); + const steady: SpeedCandidate = candidate({ + provider: "steady", + model: "m", + avgTtftMs: 350, + avgE2ELatencyMs: 1500, + avgTokensPerSecond: 60, + p95LatencyMs: 1600, + latencyStdDev: 50, + }); + const ranked = rankBySpeed([bursty, steady]); + expect(ranked[0].provider).toBe("steady"); + }); + + it("rewards higher tokens-per-second when everything else ties", () => { + const base = { + avgTtftMs: 300, + avgE2ELatencyMs: 1500, + p95LatencyMs: 1600, + }; + const lowTps: SpeedCandidate = candidate({ provider: "low", model: "m", ...base, avgTokensPerSecond: 20 }); + const highTps: SpeedCandidate = candidate({ provider: "high", model: "m", ...base, avgTokensPerSecond: 200 }); + const ranked = rankBySpeed([lowTps, highTps]); + expect(ranked[0].provider).toBe("high"); + }); +}); + +describe("rankBySpeed — factor breakdown", () => { + it("emits per-factor values in [0..1] and an explanation", () => { + const ranked = rankBySpeed([ + candidate({ + provider: "a", + model: "m", + avgTtftMs: 100, + avgE2ELatencyMs: 1000, + avgTokensPerSecond: 100, + p95LatencyMs: 1100, + latencyStdDev: 50, + }), + ]); + expect(ranked).toHaveLength(1); + const factors = ranked[0].factors; + for (const value of Object.values(factors)) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + expect(ranked[0].reason).toMatch(/SpeedRanking\[/); + expect(ranked[0].reason).toMatch(/ttft=100ms/); + }); + + it("falls back to 0.5 per missing metric so new providers are not crushed", () => { + const ranked = rankBySpeed([candidate({ provider: "fresh", model: "m" })]); + expect(ranked).toHaveLength(1); + // No telemetry at all → weighted sum lands near 0.5 with reliability multiplier 1 + expect(ranked[0].factors.reliability).toBe(1); + expect(ranked[0].factors.health).toBe(1); + expect(ranked[0].factors.ttft).toBe(0.5); + expect(ranked[0].factors.tps).toBe(0.5); + }); +}); + +describe("rankBySpeed — weight overrides", () => { + it("respects caller weight overrides (e.g. heavy TTFT bias)", () => { + const fastTtft: SpeedCandidate = candidate({ + provider: "fast", + model: "m", + avgTtftMs: 100, + avgE2ELatencyMs: 5000, + avgTokensPerSecond: 5, + p95LatencyMs: 6000, + }); + const slowTtft: SpeedCandidate = candidate({ + provider: "slow", + model: "m", + avgTtftMs: 800, + avgE2ELatencyMs: 1200, + avgTokensPerSecond: 120, + p95LatencyMs: 1300, + }); + + const normal = rankBySpeed([fastTtft, slowTtft]); + // Normal weights still pick slowTtft because TPS/E2E dominate over TTFT gaps. + expect(normal[0].provider).toBe("slow"); + + const heavyTtft = rankBySpeed([fastTtft, slowTtft], { + ...DEFAULT_SPEED_WEIGHTS, + ttft: 0.7, + tps: 0.05, + e2e: 0.05, + p95: 0.05, + health: 0.05, + reliability: 0.05, + stability: 0.05, + }); + expect(heavyTtft[0].provider).toBe("fast"); + }); +}); + +describe("pickFastest", () => { + it("returns null on an empty pool", () => { + expect(pickFastest([])).toBeNull(); + }); + + it("returns the top-ranked candidate", () => { + const fast = candidate({ provider: "fast", model: "m", avgTtftMs: 100 }); + const slow = candidate({ provider: "slow", model: "m", avgTtftMs: 900 }); + const winner = pickFastest([slow, fast]); + expect(winner?.provider).toBe("fast"); + }); +}); \ No newline at end of file diff --git a/open-sse/services/autoCombo/routerStrategy.ts b/open-sse/services/autoCombo/routerStrategy.ts index 466d815607..b9923fe470 100644 --- a/open-sse/services/autoCombo/routerStrategy.ts +++ b/open-sse/services/autoCombo/routerStrategy.ts @@ -14,6 +14,8 @@ import type { ProviderCandidate, ScoredProvider } from "./scoring.ts"; import { scorePool } from "./scoring.ts"; import { getTaskFitness } from "./taskFitness.ts"; import { clamp01 } from "../../utils/number.ts"; +import { rankBySpeed } from "./speedRanking.ts"; +import type { SpeedCandidate } from "./speedRanking.ts"; export interface SlaRoutingPolicy { targetP95Ms?: number; @@ -49,6 +51,36 @@ export interface RouterStrategy { // ── RulesStrategy: wraps 6-factor scoring engine ──────────────────────────── +function toSpeedCandidate(c: ProviderCandidate): SpeedCandidate { + return { + // Identity + provider: c.provider, + model: c.model, + // Resource state + quotaRemaining: c.quotaRemaining, + quotaTotal: c.quotaTotal, + circuitBreakerState: c.circuitBreakerState, + // Costs + costPer1MTokens: c.costPer1MTokens, + // Latency metrics + p95LatencyMs: c.p95LatencyMs, + avgTtftMs: c.avgTtftMs, + avgE2ELatencyMs: c.avgE2ELatencyMs, + avgTokensPerSecond: c.avgTokensPerSecond, + latencyStdDev: c.latencyStdDev, + // Reliability + errorRate: c.errorRate, + failureRate: c.failureRate, + // Tier signals (forwarded so weights stay available for downstream tuning) + accountTier: c.accountTier, + quotaResetIntervalSecs: c.quotaResetIntervalSecs, + contextAffinity: c.contextAffinity, + resetWindowAffinity: c.resetWindowAffinity, + connectionPoolSize: c.connectionPoolSize, + connectionId: c.connectionId, + }; +} + class RulesStrategyImpl implements RouterStrategy { readonly name = "rules"; readonly description = @@ -100,105 +132,48 @@ class CostStrategyImpl implements RouterStrategy { // ── LatencyStrategy: prioritize low latency + reliability ─────────────────── -function positiveMetric(value: unknown): number | null { - const numericValue = Number(value); - return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null; -} - -function boundedRate(value: unknown): number { - const numericValue = Number(value); - return Number.isFinite(numericValue) && numericValue >= 0 ? Math.min(1, numericValue) : 0; -} - -function maxPositiveMetric( - candidates: ProviderCandidate[], - readMetric: (candidate: ProviderCandidate) => unknown, - fallback = 1 -): number { - return Math.max( - ...candidates.map((candidate) => positiveMetric(readMetric(candidate)) ?? 0), - fallback - ); -} - -function latencyMetricScore(value: number | null, maxValue: number): number { - if (value == null) return 0.5; - return inverseNormalized(value, maxValue); -} - -function throughputMetricScore(value: number | null, maxValue: number): number { - if (value == null) return 0.5; - return clamp01(value / Math.max(maxValue, 0.000_001)); -} - class LatencyStrategyImpl implements RouterStrategy { readonly name = "latency"; readonly description = "Prioritizes the fastest reliable provider-model pair using TTFT, TPS, E2E latency, health, fail rate, and stability"; select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision { - const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN"); - const candidates = healthy.length > 0 ? healthy : pool; - if (candidates.length === 0) throw new Error("[LatencyStrategy] No candidates available"); - - const maxP95 = maxPositiveMetric(candidates, (candidate) => candidate.p95LatencyMs); - const maxTtft = maxPositiveMetric( - candidates, - (candidate) => candidate.avgTtftMs ?? candidate.p95LatencyMs - ); - const maxE2E = maxPositiveMetric( - candidates, - (candidate) => candidate.avgE2ELatencyMs ?? candidate.p95LatencyMs - ); - const maxTps = maxPositiveMetric(candidates, (candidate) => candidate.avgTokensPerSecond); - const maxStdDev = maxPositiveMetric(candidates, (candidate) => candidate.latencyStdDev, 0.001); - - const scored = candidates - .map((candidate) => { - const p95 = positiveMetric(candidate.p95LatencyMs); - const ttft = positiveMetric(candidate.avgTtftMs) ?? p95; - const e2e = positiveMetric(candidate.avgE2ELatencyMs) ?? p95; - const tps = positiveMetric(candidate.avgTokensPerSecond); - const failureRate = boundedRate(candidate.failureRate ?? candidate.errorRate); - const healthScore = getHealthScore(candidate); - const p95Score = latencyMetricScore(p95, maxP95); - const ttftScore = latencyMetricScore(ttft, maxTtft); - const e2eScore = latencyMetricScore(e2e, maxE2E); - const throughputScore = throughputMetricScore(tps, maxTps); - const reliabilityScore = 1 - failureRate; - const stabilityScore = latencyMetricScore( - positiveMetric(candidate.latencyStdDev), - maxStdDev - ); - const rawScore = - ttftScore * 0.25 + - throughputScore * 0.2 + - e2eScore * 0.18 + - p95Score * 0.12 + - reliabilityScore * 0.15 + - healthScore * 0.05 + - stabilityScore * 0.05; - const reliabilityMultiplier = Math.max(0.05, reliabilityScore * reliabilityScore); - const score = rawScore * reliabilityMultiplier * Math.max(0.25, healthScore); - - return { candidate, score, ttft, e2e, tps, failureRate }; - }) - .sort((a, b) => b.score - a.score); - - const best = scored[0]; - if (!best) throw new Error("[LatencyStrategy] No candidates available"); + const ranked = rankBySpeed(pool.map(toSpeedCandidate)); + const winner = ranked[0]; + if (!winner) { + throw new Error("[LatencyStrategy] No candidates available after speed ranking"); + } return { - provider: best.candidate.provider, - model: best.candidate.model, + provider: winner.provider, + model: winner.model, strategy: this.name, - reason: `LatencyStrategy: ttft=${best.ttft ?? "n/a"}ms, tps=${best.tps ?? "n/a"}, e2e=${best.e2e ?? "n/a"}ms, p95=${best.candidate.p95LatencyMs}ms, failRate=${(best.failureRate * 100).toFixed(2)}%`, - candidatesConsidered: candidates.length, - finalScore: best.score, + reason: latencyDecisionReason(winner), + candidatesConsidered: ranked.length, + finalScore: winner.score, }; } } +function metricString(value: number | null | undefined, digits = 0): string { + return value == null ? "n/a" : value.toFixed(digits); +} + +function latencyDecisionReason(winner: ReturnType[number]): string { + const metrics = winner.metrics; + const e2e = metrics.avgE2ELatencyMs ?? metrics.p95LatencyMs; + return ( + `LatencyStrategy(score=${winner.score.toFixed(3)}): ` + + `ttft=${metricString(metrics.avgTtftMs)}ms ` + + `tps=${metricString(metrics.avgTokensPerSecond, 1)} ` + + `e2e=${metricString(e2e)}ms ` + + `p95=${metricString(metrics.p95LatencyMs)}ms ` + + `failRate=${((metrics.failureRate ?? 0) * 100).toFixed(2)}% ` + + `stability=${metricString(metrics.latencyStdDev)}ms ` + + `cb=${metrics.circuitBreakerState ?? "n/a"}` + ); +} + // ── SLAStrategy: favor targets that meet latency/error/cost SLOs ─────────── const DEFAULT_SLA_TARGET_P95_MS = 2_000; diff --git a/open-sse/services/autoCombo/speedRanking.ts b/open-sse/services/autoCombo/speedRanking.ts new file mode 100644 index 0000000000..514420a5a0 --- /dev/null +++ b/open-sse/services/autoCombo/speedRanking.ts @@ -0,0 +1,327 @@ +/** + * Speed-optimized Provider×Model Ranking + * + * Pure, framework-free scoring function that ranks provider×model candidates by + * the speed/reliability combination most likely to make a request feel "fast": + * + * - lower avg Time-To-First-Token (TTFT) — perceived responsiveness + * - higher avg tokens-per-second (TPS) — generation throughput + * - lower avg end-to-end (E2E) latency — full completion time + * - lower p95 latency — tail-risk backup metric + * - higher circuit-breaker health — must be reachable + * - lower failure / error rate — speed without flake + * - lower latency standard deviation (stability)— consistent, not bursty + * + * Every metric is optional; missing telemetry is treated as the pool median + * (i.e. 0.5 in [0..1]) so a brand-new provider/model does not get crushed, but + * also does not get a free pass on a metric we have no data for. This mirrors + * the behavior of the existing `LatencyStrategyImpl` and is intentional — the + * function is the canonical ranking for the "fastest reliable provider-model" + * UX in the playground + MCP `omniroute_pick_fastest_model` tool, and is + * reused by the runtime `LatencyStrategyImpl` so the runtime router picks the + * same winner as the user-facing preview. + * + * Optional weights override defaults so callers (tests, dashboards, future + * `modePacks.speed-first`) can rebalance without duplicating the math. + */ + +import type { ProviderCandidate } from "./scoring.ts"; + +/** Optional per-candidate telemetry surfaced by the ranking. */ +export interface SpeedCandidate extends ProviderCandidate { + /** Numeric health score [0..1]; computed from circuitBreakerState when missing. */ + health?: number; + /** Percentage quota remaining in [0..100]. Falls back to quotaRemaining. */ + quotaRemainingPct?: number; + /** Numeric capacity score [0..1] in case the caller wants to expose it. */ + capacityScore?: number; + /** Cached cost-per-1k tokens (denormalized from costPer1MTokens). */ + costPer1k?: number; + /** Optional quality score for the candidate (e.g. eval benchmark). */ + qualityScore?: number; + /** Optional strategic boost applied for premium/internal models. */ + strategicBoost?: number; + /** Optional SLO-violation penalty that should be subtracted from the raw score. */ + sloPenalty?: number; +} + +/** Per-factor contribution of a single candidate (each value clamped to [0..1]). */ +export interface SpeedFactors { + ttft: number; + tps: number; + e2e: number; + p95: number; + health: number; + reliability: number; + stability: number; +} + +/** Result of ranking a pool of candidates. */ +export interface SpeedRankedCandidate { + provider: string; + model: string; + /** Final composite score in [0..1]; higher is faster+more-reliable. */ + score: number; + factors: SpeedFactors; + /** Raw telemetry we observed for the candidate (in provider-native units). */ + metrics: { + avgTtftMs: number | null; + avgTokensPerSecond: number | null; + avgE2ELatencyMs: number | null; + p95LatencyMs?: number | null; + latencyStdDev: number | null; + failureRate: number; + circuitBreakerState: SpeedCandidate["circuitBreakerState"]; + }; + /** Human-readable explanation of why this candidate earned its score. */ + reason: string; +} + +/** Caller-tunable weights; defaults are documented at each property. */ +export interface SpeedRankingWeights { + /** Weight for TTFT (default 0.25). */ + ttft: number; + /** Weight for tokens/sec (default 0.20). */ + tps: number; + /** Weight for end-to-end latency (default 0.18). */ + e2e: number; + /** Weight for p95 latency fallback (default 0.12). */ + p95: number; + /** Weight for circuit-breaker health (default 0.05). */ + health: number; + /** Weight for reliability = 1 - failureRate (default 0.15). */ + reliability: number; + /** Weight for latency stability / low std-dev (default 0.05). */ + stability: number; +} + +/** + * Default weights — these sum to 1.0 and bias toward perceived speed (TTFT + + * TPS together account for 45% of the score) while still penalizing unsafe + * providers. They are deliberately exported so the playground UI can render + * the formula and tests can pin it. + */ +export const DEFAULT_SPEED_WEIGHTS: SpeedRankingWeights = { + ttft: 0.25, + tps: 0.2, + e2e: 0.18, + p95: 0.12, + health: 0.05, + reliability: 0.15, + stability: 0.05, +}; + +/** Human-readable label for each weight key — used in `reason` strings. */ +const FACTOR_LABEL: Record = { + ttft: "ttft", + tps: "tps", + e2e: "e2e", + p95: "p95", + health: "health", + reliability: "reliability", + stability: "stability", +}; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function positiveFinite(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function toBoundedRate(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0; + return Math.min(1, value); +} + +/** + * Pool-relative maximum for "lower is better" metrics. Returns at least + * `floor` so a candidate with the only positive measurement does not divide + * by zero. + */ +function poolMax( + values: ReadonlyArray, + readMetric: (value: T) => number | null, + floor = 1 +): number { + let max = floor; + for (const value of values) { + const v = readMetric(value); + if (v != null && v > max) max = v; + } + return max; +} + +/** + * Pool-relative maximum for "higher is better" metrics. Returns at least + * `floor` so a missing metric does not yield Infinity downstream. + */ +function poolMaxHigherBetter( + values: ReadonlyArray, + readMetric: (value: T) => number | null, + floor = 0.000_001 +): number { + let max = floor; + for (const value of values) { + const v = readMetric(value); + if (v != null && v > max) max = v; + } + return max; +} + +/** 1 - (value / max), clamped to [0..1]. Missing → 0.5 (pool median). */ +function lowerIsBetter(value: number | null | undefined, max: number): number { + if (value == null) return 0.5; + if (!Number.isFinite(value) || value < 0) return 0; + if (!Number.isFinite(max) || max <= 0) return 1; + return clamp01(1 - value / max); +} + +/** value / max, clamped to [0..1]. Missing → 0.5. */ +function higherIsBetter(value: number | null | undefined, max: number): number { + if (value == null) return 0.5; + if (!Number.isFinite(value) || value < 0) return 0; + if (!Number.isFinite(max) || max <= 0) return 0; + return clamp01(value / max); +} + +function healthScoreFor(state: SpeedCandidate["circuitBreakerState"]): number { + if (state === "CLOSED") return 1; + if (state === "HALF_OPEN") return 0.5; + return 0; // OPEN — caller is expected to filter these out beforehand, but be defensive. +} + +function speedPoolMaxima(pool: ReadonlyArray) { + return { + ttft: poolMax(pool, (c) => positiveFinite(c.avgTtftMs) ?? positiveFinite(c.p95LatencyMs)), + e2e: poolMax(pool, (c) => positiveFinite(c.avgE2ELatencyMs) ?? positiveFinite(c.p95LatencyMs)), + p95: poolMax(pool, (c) => positiveFinite(c.p95LatencyMs)), + tps: poolMaxHigherBetter(pool, (c) => positiveFinite(c.avgTokensPerSecond)), + stdDev: poolMax(pool, (c) => positiveFinite(c.latencyStdDev), 0.001), + }; +} + +function speedFactorsFor( + candidate: SpeedCandidate, + maxima: ReturnType, + failureRate: number +): SpeedFactors { + return { + ttft: lowerIsBetter(positiveFinite(candidate.avgTtftMs), maxima.ttft), + tps: higherIsBetter(positiveFinite(candidate.avgTokensPerSecond), maxima.tps), + e2e: lowerIsBetter(positiveFinite(candidate.avgE2ELatencyMs), maxima.e2e), + p95: lowerIsBetter(positiveFinite(candidate.p95LatencyMs), maxima.p95), + health: healthScoreFor(candidate.circuitBreakerState), + reliability: clamp01(1 - failureRate), + stability: lowerIsBetter(positiveFinite(candidate.latencyStdDev), maxima.stdDev), + }; +} + +function weightedSpeedScore(factors: SpeedFactors, weights: SpeedRankingWeights): number { + return ( + factors.ttft * weights.ttft + + factors.tps * weights.tps + + factors.e2e * weights.e2e + + factors.p95 * weights.p95 + + factors.health * weights.health + + factors.reliability * weights.reliability + + factors.stability * weights.stability + ); +} + +function applySpeedPenalties(weightedSum: number, factors: SpeedFactors): number { + const reliabilityMultiplier = Math.max(0.05, Math.pow(0.25 + 0.75 * factors.reliability, 2)); + const stabilityMultiplier = Math.max(0.05, Math.pow(0.25 + 0.75 * factors.stability, 2)); + return clamp01(weightedSum * reliabilityMultiplier * stabilityMultiplier * Math.max(0.25, factors.health)); +} + +function speedReason(candidate: SpeedCandidate, factors: SpeedFactors, metrics: SpeedRankedCandidate["metrics"]): string { + const reasonParts = [ + `ttft=${metrics.avgTtftMs == null ? "n/a" : `${Math.round(metrics.avgTtftMs)}ms`}`, + `tps=${metrics.avgTokensPerSecond == null ? "n/a" : metrics.avgTokensPerSecond.toFixed(1)}`, + `e2e=${metrics.avgE2ELatencyMs == null ? "n/a" : `${Math.round(metrics.avgE2ELatencyMs)}ms`}`, + `p95=${metrics.p95LatencyMs == null ? "n/a" : `${Math.round(metrics.p95LatencyMs)}ms`}`, + `failRate=${(metrics.failureRate * 100).toFixed(2)}%`, + `cb=${candidate.circuitBreakerState}`, + ]; + return `SpeedRanking[${FACTOR_LABEL.ttft}=${factors.ttft.toFixed(2)}, ${FACTOR_LABEL.tps}=${factors.tps.toFixed(2)}, ${FACTOR_LABEL.e2e}=${factors.e2e.toFixed(2)}, ${FACTOR_LABEL.p95}=${factors.p95.toFixed(2)}, ${FACTOR_LABEL.reliability}=${factors.reliability.toFixed(2)}, ${FACTOR_LABEL.health}=${factors.health.toFixed(2)}, ${FACTOR_LABEL.stability}=${factors.stability.toFixed(2)}] → ${reasonParts.join(", ")}`; +} + +/** + * Rank candidates for the speed-optimized routing mode. + * + * @param candidates Pool of provider×model candidates (typically the candidates + * inside an auto combo's provider pool, or any list assembled by the + * playground / MCP tool). + * @param weights Optional weight overrides — defaults to {@link DEFAULT_SPEED_WEIGHTS}. + * @param options.includeUnhealthy If false (default), OPEN circuit-breaker + * candidates are dropped before scoring. If true, they are scored with a + * health factor of 0 and a reliability factor of 0 so they sort to the + * bottom without changing the rest of the ranking. + * @returns The full ranked list, highest score first. The first entry is the + * "fastest reliable provider-model pair" for this pool. + */ +export function rankBySpeed( + candidates: ReadonlyArray, + weights: SpeedRankingWeights = DEFAULT_SPEED_WEIGHTS, + options: { includeUnhealthy?: boolean } = {} +): SpeedRankedCandidate[] { + if (candidates.length === 0) return []; + + const pool = options.includeUnhealthy + ? [...candidates] + : candidates.filter((c) => c.circuitBreakerState !== "OPEN"); + if (pool.length === 0) return []; + + const maxima = speedPoolMaxima(pool); + + const ranked = pool.map((candidate) => { + const p95 = positiveFinite(candidate.p95LatencyMs); + const ttft = positiveFinite(candidate.avgTtftMs); + const e2e = positiveFinite(candidate.avgE2ELatencyMs); + const tps = positiveFinite(candidate.avgTokensPerSecond); + const stdDev = positiveFinite(candidate.latencyStdDev); + const failureRate = toBoundedRate( + candidate.failureRate ?? (typeof candidate.errorRate === "number" ? candidate.errorRate : 0) + ); + const factors = speedFactorsFor(candidate, maxima, failureRate); + const score = applySpeedPenalties(weightedSpeedScore(factors, weights), factors); + const metrics = { + avgTtftMs: ttft, + avgTokensPerSecond: tps, + avgE2ELatencyMs: e2e, + p95LatencyMs: p95, + latencyStdDev: stdDev, + failureRate, + circuitBreakerState: candidate.circuitBreakerState, + }; + + return { + provider: candidate.provider, + model: candidate.model, + score, + factors, + metrics, + reason: speedReason(candidate, factors, metrics), + }; + }); + + return ranked.sort((a, b) => b.score - a.score); +} + +/** + * Convenience selector — returns the top-ranked candidate or `null` when the + * pool is empty. Used by the runtime `LatencyStrategyImpl` and the MCP + * `omniroute_pick_fastest_model` tool when only the winner is needed. + */ +export function pickFastest( + candidates: ReadonlyArray, + weights: SpeedRankingWeights = DEFAULT_SPEED_WEIGHTS +): SpeedRankedCandidate | null { + const ranked = rankBySpeed(candidates, weights); + return ranked.length > 0 ? ranked[0] : null; +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c9fd70f58f..4fd5f22711 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -54,6 +54,7 @@ const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ ]; const CONTEXT_1M_SUPPORTED_MODELS = [ "claude-fable-5", + "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", @@ -175,13 +176,12 @@ export function buildClaudeCodeCompatibleHeaders( sessionId?: string | null, options: { redactThinking?: boolean } = {} ): Record { - void stream; // These headers intentionally mirror Claude Code's wire image closely. // For CC-compatible relays, passing the upstream's client-gating checks is // more important than forwarding arbitrary caller-specific header shapes. return { "Content-Type": "application/json", - Accept: "application/json", + Accept: stream ? "text/event-stream" : "application/json", Authorization: `Bearer ${apiKey}`, "anthropic-version": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION, "anthropic-beta": resolveClaudeCodeCompatibleAnthropicBeta({ diff --git a/open-sse/services/clinepassModels.ts b/open-sse/services/clinepassModels.ts new file mode 100644 index 0000000000..ee25f6e8e5 --- /dev/null +++ b/open-sse/services/clinepassModels.ts @@ -0,0 +1,76 @@ +import { buildClineHeaders } from "@/shared/utils/clineAuth"; + +// ClinePass live-models resolver. ClinePass is API-key-only (BYOK), but the +// underlying api.cline.bot host also accepts the OAuth `cline` credential shape, +// so the resolver reuses buildClineHeaders() (the shared workos:-prefixed Cline +// header set) for the non-apikey path. Only `cline-pass/*` model ids are kept. + +const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +const FETCH_TIMEOUT_MS = 5000; + +export interface ClinepassModel { + id: string; + name: string; +} + +/** + * Filter a raw models list down to the ClinePass namespace (`cline-pass/*`). + * Pure — shared by the live resolver and the discovery-config parseResponse. + */ +export function filterClinepassModels(rawList: unknown): ClinepassModel[] { + if (!Array.isArray(rawList)) return []; + return rawList + .filter( + (m): m is { id: string; name?: string } => + !!m && + typeof (m as { id?: unknown }).id === "string" && + (m as { id: string }).id.startsWith("cline-pass/") + ) + .map((m) => ({ id: m.id, name: m.name || m.id })); +} + +function buildModelListHeaders(token: string, isApiKey: boolean): Record { + if (isApiKey) { + return { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + } + return buildClineHeaders(token, { Accept: "application/json" }); +} + +/** + * Resolve the live ClinePass model catalogue for a connection. Returns + * `{ models }` on success or `null` on any failure (missing token, non-2xx, + * bad shape, timeout) so callers fall back to the static registry catalogue. + */ +export async function resolveClinepassModels(credentials: { + apiKey?: string | null; + accessToken?: string | null; +}): Promise<{ models: ClinepassModel[] } | null> { + const isApiKey = Boolean(credentials?.apiKey); + const token = isApiKey ? credentials.apiKey : credentials?.accessToken; + if (!token) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const headers = buildModelListHeaders(token, isApiKey); + const response = await fetch(CLINEPASS_MODELS_ENDPOINT, { + method: "GET", + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + + const json = await response.json(); + const rawList = Array.isArray(json) ? json : json?.data; + const models = filterClinepassModels(rawList); + return models.length ? { models } : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index 8a2ed5e859..0cb914e884 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -49,6 +49,13 @@ export interface CodexDualWindowQuota extends QuotaInfo { limitReached: boolean; /** All known Codex quota windows, including Spark when the upstream exposes it. */ allWindows?: Record; + /** + * Banked reset credits available on the account (display-only, issue #5199). + * Eligibility-gated: absent for most accounts. Never throws when missing. + */ + bankedResetCredits?: number; + /** Which window is currently reported as blocking, when the upstream exposes it. */ + rateLimitReachedType?: string; } interface CacheEntry { @@ -295,6 +302,26 @@ function parseCodexWindow( return { percentUsed, resetAt: parseWindowReset(window) }; } +/** + * Codex "banked reset credits" — same eligibility-gated field parsed in + * codexUsageQuotas.ts (kept in sync manually; the two parsers read the same + * /wham/usage payload independently). DISPLAY ONLY, never throws. + */ +function parseBankedResetCredits(data: Record): number | undefined { + const resetCredits = toRecord(data["rate_limit_reset_credits"] ?? data["rateLimitResetCredits"]); + const availableCount = resetCredits["available_count"] ?? resetCredits["availableCount"]; + const count = toNumber(availableCount, NaN); + return Number.isFinite(count) ? count : undefined; +} + +function parseRateLimitReachedType(data: Record): string | undefined { + const reachedType = data["rate_limit_reached_type"] ?? data["rateLimitReachedType"]; + if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim(); + const reachedTypeObj = toRecord(reachedType); + const type = reachedTypeObj["type"]; + return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined; +} + function findSparkRateLimit(data: Record): Record | null { const additional = data["additional_rate_limits"] ?? data["additionalRateLimits"]; if (!Array.isArray(additional)) return null; @@ -403,6 +430,9 @@ function parseCodexUsageResponse( secondary: CODEX_WINDOW_WEEKLY, }); + const bankedResetCredits = parseBankedResetCredits(obj); + const rateLimitReachedType = parseRateLimitReachedType(obj); + return { used: Math.round(worstPercentUsed * 100), total: 100, @@ -419,6 +449,9 @@ function parseCodexUsageResponse( window5h, window7d, limitReached, + // Banked reset credits (display-only, eligibility-gated — issue #5199). + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), }; } diff --git a/open-sse/services/codexUsageQuotas.ts b/open-sse/services/codexUsageQuotas.ts index 6696daca0b..4d0bc0d857 100644 --- a/open-sse/services/codexUsageQuotas.ts +++ b/open-sse/services/codexUsageQuotas.ts @@ -160,13 +160,43 @@ function findCodexReviewRateLimit(data: JsonRecord): JsonRecord { return {}; } +/** + * Codex "banked reset credits" — an eligibility-gated field some ChatGPT plans + * expose on the /wham/usage payload: a count of extra rate-limit resets the + * account has banked (available_count), plus an optional descriptor of which + * window is currently blocking (rate_limit_reached_type). DISPLAY ONLY — this + * reads the field defensively (many accounts will not have it) and never + * throws; redemption is an unofficial mutating endpoint and out of scope + * (issue #5199). + */ +function parseBankedResetCredits(data: JsonRecord): number | undefined { + const resetCredits = toRecord(getFieldValue(data, "rate_limit_reset_credits", "rateLimitResetCredits")); + const availableCount = getFieldValue(resetCredits, "available_count", "availableCount"); + const count = toNumber(availableCount, NaN); + return Number.isFinite(count) ? count : undefined; +} + +function parseRateLimitReachedType(data: JsonRecord): string | undefined { + const reachedType = getFieldValue(data, "rate_limit_reached_type", "rateLimitReachedType"); + if (typeof reachedType === "string" && reachedType.trim().length > 0) return reachedType.trim(); + const reachedTypeObj = toRecord(reachedType); + const type = getFieldValue(reachedTypeObj, "type"); + return typeof type === "string" && type.trim().length > 0 ? type.trim() : undefined; +} + export function buildCodexUsageQuotas(dataValue: unknown): { rateLimit: JsonRecord; quotas: Record; + /** Banked reset credits available on the account (undefined when absent/not eligible). */ + bankedResetCredits?: number; + /** Which window is currently reported as blocking, when the upstream exposes it. */ + rateLimitReachedType?: string; } { const data = toRecord(dataValue); const rateLimit = toRecord(getFieldValue(data, "rate_limit", "rateLimit")); const quotas: Record = {}; + const bankedResetCredits = parseBankedResetCredits(data); + const rateLimitReachedType = parseRateLimitReachedType(data); const primaryWindow = toRecord(getFieldValue(rateLimit, "primary_window", "primaryWindow")); if (Object.keys(primaryWindow).length > 0) quotas.session = buildPercentageQuota(primaryWindow); @@ -231,5 +261,10 @@ export function buildCodexUsageQuotas(dataValue: unknown): { ); } - return { rateLimit, quotas }; + return { + rateLimit, + quotas, + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), + }; } diff --git a/open-sse/services/codexVerbosity.ts b/open-sse/services/codexVerbosity.ts index 8b530038c6..96cc441faa 100644 --- a/open-sse/services/codexVerbosity.ts +++ b/open-sse/services/codexVerbosity.ts @@ -9,9 +9,8 @@ * * This helper runs on the translated path (before the allowlist) and folds whichever * shape arrived into a single, validated `text:{verbosity}`. `text` is then added to - * the allowlist so the hint survives. Non-verbosity `text` keys (e.g. a stray - * `text.format`) are intentionally dropped — they were already stripped by the - * pre-existing allowlist, so this preserves the status quo while adding verbosity. + * the allowlist so the hint survives. Other Responses `text` keys, such as + * `text.format` for structured output, are preserved. * * Ref: OpenAI GPT-5 docs (text.verbosity), Azure Foundry reasoning guide. */ @@ -33,8 +32,8 @@ function normalizeLevel(value: unknown): string | undefined { /** * Mutates `body` in place: resolves verbosity from `text.verbosity` (Responses) or * the top-level `verbosity` (Chat Completions, which takes precedence when both are - * present), drops the Chat-only top-level field, and collapses `text` to - * `{verbosity}` when valid or removes it otherwise. + * present), drops the Chat-only top-level field, and preserves any other existing + * Responses `text` configuration. */ export function normalizeCodexVerbosity(body: Record): void { const textRecord = asRecord(body.text); @@ -45,8 +44,15 @@ export function normalizeCodexVerbosity(body: Record): void { delete body.verbosity; + const nextText = textRecord ? { ...textRecord } : {}; if (verbosity) { - body.text = { verbosity }; + nextText.verbosity = verbosity; + } else { + delete nextText.verbosity; + } + + if (Object.keys(nextText).length > 0) { + body.text = nextText; } else { delete body.text; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c99c9bbd1d..4ce9712934 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -18,12 +18,8 @@ import { selectLockoutCooldownMs, } from "./accountFallback.ts"; import { errorResponse, unavailableResponse } from "../utils/error.ts"; -import { - recordComboIntent, - recordComboRequest, - recordComboShadowRequest, - getComboMetrics, -} from "./comboMetrics.ts"; +import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts"; +import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts"; import { resolveComboConfig, getDefaultComboConfig, @@ -45,12 +41,7 @@ import { extractSessionAffinityKey } from "@/sse/services/auth"; import { getHiddenModelsByProvider } from "@/models"; import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings"; import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; -import { - evaluateQuotaCutoff, - getQuotaFetcher, - type PreflightQuotaThresholds, - type QuotaInfo, -} from "./quotaPreflight.ts"; +import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck"; @@ -60,31 +51,18 @@ import { phaseComboSetup } from "./combo/comboSetup.ts"; import { checkCredentialGate, logCredentialSkip } from "./credentialGate.ts"; import { emit } from "../../src/lib/events/eventBus"; import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher"; -import { classifyWithConfig } from "./intentClassifier.ts"; -import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts"; -import { selectWithStrategy } from "./autoCombo/routerStrategy.ts"; import { parseAutoPrefix } from "./autoCombo/autoPrefix.ts"; +import { resolveAutoStrategyOrder } from "./combo/resolveAutoStrategy.ts"; +import { applyStrategyOrdering } from "./combo/applyStrategyOrdering.ts"; import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipelineRouter.ts"; -import { - DEFAULT_WEIGHTS, - type ProviderCandidate, - type ScoringWeights, -} from "./autoCombo/scoring.ts"; -import { supportsToolCalling } from "./modelCapabilities.ts"; +import { type ProviderCandidate } from "./autoCombo/scoring.ts"; import { estimateTokens } from "./contextManager.ts"; import { getSessionConnection } from "./sessionManager.ts"; import { applySessionStickiness, recordStickyBinding } from "./combo/sessionStickiness.ts"; import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts"; -import { - resolveMaxConcurrentByConnection, - makeConnectionConcurrencyResolver, - lookupPositiveCap, -} from "./combo/concurrencyCaps.ts"; +import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts"; import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts"; import { orderTargetsByEvalScores } from "./evalRouting.ts"; -import { generateRoutingHints } from "./manifestAdapter"; -import type { RoutingHint } from "./manifestAdapter"; -import { buildComplexityRoutingHint } from "./autoCombo/complexityRouter"; import type { CompressionMode } from "./compression/types.ts"; import { getProviderConnections } from "../../src/lib/db/providers"; import { @@ -149,52 +127,42 @@ import { } from "./combo/comboPredicates.ts"; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; import { executeRuntimeUnitCombo } from "./combo/runtimeUnits.ts"; -import { dedupeTargetsByExecutionKey, isRecord } from "./combo/comboData.ts"; +import { isRecord } from "./combo/comboData.ts"; import { expandProviderWildcardsInCombo, expandProviderWildcardsInCollection, } from "./combo/providerWildcard.ts"; import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts"; -import { - sortTargetsByCost, - sortTargetsByUsage, - orderTargetsByPowerOfTwoChoices, -} from "./combo/targetSorters.ts"; import { filterTargetsByRequestCompatibility, - getModelContextLimitForModelString, resolveComboRuntimeUnits, resolveComboTargets, resolveWeightedTargets, resolveWeightedStepGroups, - sortTargetsByContextSize, } from "./combo/comboStructure.ts"; import { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty, _registerExecutionCandidates, _unregisterExecutionCandidates, - extractPromptForIntent, - mapIntentToTaskType, - getIntentConfig, applyRequestTagRouting, scoreAutoTargets, expandAutoComboCandidatePool, } from "./combo/autoStrategy.ts"; import { resolveResetWindowConfig, - resolveSlaRoutingPolicy, calculateResetWindowAffinity, type ResetWindowConfig, } from "./combo/quotaScoring.ts"; import { fetchResetAwareQuotaWithCache, preScreenTargets, - orderTargetsByResetAwareQuota, - orderTargetsByResetWindow, - orderTargetsByHeadroom, type PreScreenResult, } from "./combo/quotaStrategies.ts"; +import { + buildAutoQuotaThresholds, + resolveQuotaExhaustionCutoffForTarget, +} from "./combo/quotaExhaustionCutoff.ts"; import { classifyTask, getConversationCacheKey, @@ -257,55 +225,6 @@ function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } -function asThresholdMap(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const result: Record = {}; - for (const [key, raw] of Object.entries(value as Record)) { - const numeric = Number(raw); - if (key && Number.isFinite(numeric)) result[key] = numeric; - } - return result; -} - -function quotaWindowLookupNames(provider: string, windowName: string): string[] { - const names = [windowName]; - const lower = windowName.toLowerCase(); - if (lower !== windowName) names.push(lower); - if (provider === "codex") { - if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session"); - if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly"); - if (lower.includes("monthly") || lower === "30d") names.push("monthly"); - } - return [...new Set(names)]; -} - -function buildAutoQuotaThresholds( - provider: string, - connection: Record | undefined, - resilienceSettings: ResilienceSettings | null | undefined -): PreflightQuotaThresholds { - const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight; - const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2; - const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20; - const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]); - const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds); - - return { - resolveMinRemainingPercent: (windowName: string | null): number => { - if (windowName !== null) { - for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) { - const override = perConnectionWindowOverrides[lookupWindowName]; - if (typeof override === "number") return override; - const providerDefault = providerWindowMap[lookupWindowName]; - if (typeof providerDefault === "number") return providerDefault; - } - } - return defaultThresholdPercent; - }, - resolveWarnRemainingPercent: () => warnThresholdPercent, - }; -} - function quotaRemainingPercentFromQuota(quota: unknown): number { if (!quota || typeof quota !== "object") return 100; const record = quota as Record; @@ -734,72 +653,11 @@ export async function handleComboChat({ } = phaseComboSetup(comboCtx); body = comboCtx.body; - const handleSingleModelWithTimeout = async ( - b: Record, - modelStr: string, - target?: SingleModelTarget - ): Promise => { - if (comboTargetTimeoutMs <= 0) { - return handleSingleModel(b, modelStr, target).catch((err) => - errorResponse(502, err?.message ?? "Upstream model error") - ); - } - - const timeoutController = new AbortController(); - let timeoutId: ReturnType | undefined; - let timedOut = false; - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => { - timedOut = true; - log.warn( - "COMBO", - `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` - ); - timeoutController.abort(new Error("combo-per-model-timeout")); - resolve( - new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), { - status: 524, - headers: { "Content-Type": "application/json" }, - }) - ); - }, comboTargetTimeoutMs); - }); - const targetWithSignal = { - ...(target ?? {}), - modelAbortSignal: timeoutController.signal, - }; - const parentHedgeSignal = target?.modelAbortSignal ?? null; - let onParentHedgeAbort: (() => void) | null = null; - if (parentHedgeSignal) { - if (parentHedgeSignal.aborted) { - timeoutController.abort(new Error("hedge-cancelled")); - } else { - onParentHedgeAbort = () => { - timeoutController.abort(new Error("hedge-cancelled")); - }; - parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true }); - } - } - try { - return await Promise.race([ - handleSingleModel(b, modelStr, targetWithSignal).catch((err) => { - if (timedOut) { - // Inner call rejected because we aborted it. The synthetic 524 from - // timeoutPromise already wins the race; return an empty response so - // the loser branch resolves cleanly without leaking err.message. - return new Response(null, { status: 599 }); - } - return errorResponse(502, err?.message ?? "Upstream model error"); - }), - timeoutPromise, - ]); - } finally { - clearTimeout(timeoutId); - if (parentHedgeSignal && onParentHedgeAbort) { - parentHedgeSignal.removeEventListener("abort", onParentHedgeAbort); - } - } - }; + const handleSingleModelWithTimeout = buildTargetTimeoutRunner({ + handleSingleModel, + comboTargetTimeoutMs, + log, + }); // Route to pinned model if context caching specifies one (Fix #679) if (pinnedModel) { @@ -1176,422 +1034,28 @@ export async function handleComboChat({ // the fallback order, never override the router's primary choice. let autoUsedExplicitRouter = false; if (strategy === "auto") { - const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0; - let eligibleTargets = [...orderedTargets]; - - if (requestHasTools) { - const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr)); - if (filtered.length > 0) { - eligibleTargets = filtered; - } else { - log.warn( - "COMBO", - "Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool" - ); - } - } - - // Context-window pre-filter (#1808) - // Estimate input tokens once; exclude candidates whose known context limit is too small. - // Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext(). - // Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets. - const requestMessages = body.messages; - const estimatedInputTokens = estimateTokens( - typeof requestMessages === "string" || - (requestMessages !== null && typeof requestMessages === "object") - ? requestMessages - : [] - ); - if (estimatedInputTokens > 0) { - const filteredByContext = eligibleTargets.filter((target) => { - const limit = getModelContextLimitForModelString(target.modelStr); - if (limit === null || limit === undefined) return true; // unknown — include to be safe - return limit >= estimatedInputTokens; - }); - if (filteredByContext.length > 0) { - log.debug?.( - "COMBO", - `Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)` - ); - eligibleTargets = filteredByContext; - } else { - log.warn( - "COMBO", - `Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool` - ); - // eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter - } - - eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo); - } - - const prompt = extractPromptForIntent(body); - const systemPrompt = - typeof combo?.system_message === "string" ? combo.system_message : undefined; - const intentConfig = getIntentConfig(settings, combo); - const intent = classifyWithConfig(prompt, intentConfig, systemPrompt); - recordComboIntent(combo.name, intent); - const taskType = mapIntentToTaskType(intent); - - const rawAutoConfigSource = - combo?.autoConfig || - (isRecord(combo?.config?.auto) ? combo.config.auto : null) || - combo?.config || - {}; - const autoConfigSource: Record = isRecord(rawAutoConfigSource) - ? rawAutoConfigSource - : {}; - const routingStrategy = - typeof autoConfigSource.routerStrategy === "string" - ? autoConfigSource.routerStrategy - : typeof autoConfigSource.routingStrategy === "string" - ? autoConfigSource.routingStrategy - : typeof autoConfigSource.strategyName === "string" - ? autoConfigSource.strategyName - : "rules"; - - const candidatePool = Array.isArray(autoConfigSource.candidatePool) - ? autoConfigSource.candidatePool - : [...new Set(eligibleTargets.map((target) => target.provider))]; - - const weights = - autoConfigSource.weights && typeof autoConfigSource.weights === "object" - ? (autoConfigSource.weights as ScoringWeights) - : DEFAULT_WEIGHTS; - const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate)) - ? Number(autoConfigSource.explorationRate) - : 0.05; - const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap)) - ? Number(autoConfigSource.budgetCap) - : undefined; - const modePack = - typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined; - const resetWindowConfig = resolveResetWindowConfig(autoConfigSource); - const slaPolicy = resolveSlaRoutingPolicy(autoConfigSource); - - let lastKnownGoodProvider: string | undefined; - try { - const { getLKGP } = await import("../../src/lib/localDb"); - const lkgp = await getLKGP(combo.name, combo.id || combo.name); - if (lkgp) lastKnownGoodProvider = lkgp.provider; - } catch (err) { - log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err }); - } - - const autoCandidateResilienceSettings = - relayOptions?.bypassProviderQuotaPolicy === true - ? { - ...resilienceSettings, - quotaPreflight: { - ...resilienceSettings.quotaPreflight, - enabled: false, - }, - } - : resilienceSettings; - const candidates = await buildAutoCandidates( - eligibleTargets, - combo.name, - relayOptions?.sessionId, - resetWindowConfig, - autoCandidateResilienceSettings - ); - const routableCandidates = candidates.filter( - (candidate) => candidate.quotaCutoffBlocked !== true - ); - const quotaBlockedCount = candidates.length - routableCandidates.length; - if (quotaBlockedCount > 0) { - log.info( - "COMBO", - `Auto strategy: quota cutoff skipped ${quotaBlockedCount}/${candidates.length} account candidates` - ); - } - // G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty. - _registerExecutionCandidates(routableCandidates); - if (candidates.length > 0 && routableCandidates.length === 0) { - return unavailableResponse( - 429, - "All auto strategy candidates are below configured quota cutoffs" - ); - } - if (routableCandidates.length > 0) { - let selectedProvider: string | null = null; - let selectedModel: string | null = null; - let selectionReason = ""; - - if (routingStrategy !== "rules") { - try { - const decision = selectWithStrategy( - routableCandidates, - { - taskType, - requestHasTools, - lastKnownGoodProvider, - estimatedInputTokens, - sla: slaPolicy, - }, - routingStrategy - ); - selectedProvider = decision.provider; - selectedModel = decision.model; - selectionReason = decision.reason; - autoUsedExplicitRouter = true; - } catch (err) { - log.warn( - "COMBO", - `Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules` - ); - } - } - - if (!selectedProvider || !selectedModel) { - const selection = selectAutoProvider( - { - id: combo.id || combo.name, - name: combo.name, - type: "auto", - candidatePool, - weights, - modePack, - budgetCap, - explorationRate, - }, - routableCandidates, - taskType - ); - selectedProvider = selection.provider; - selectedModel = selection.model; - selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`; - } - - // Complexity-aware routing (2026, opt-in): classify the request's - // difficulty and feed a tier hint into scoring so tierAffinity / - // specificityMatch favor candidates whose tier matches the request. - const autoManifestHint: RoutingHint | null = - config.complexityAwareRouting === true - ? buildComplexityRoutingHint( - eligibleTargets.filter((t) => t.kind === "model"), - body, - log - ) - : null; - - const scoredTargets = scoreAutoTargets( - eligibleTargets, - routableCandidates, - taskType, - weights, - autoManifestHint - ); - const rankedTargets = scoredTargets.map((entry) => entry.target); - const selectedTarget = - scoredTargets.find((entry) => { - const parsed = parseModel(entry.target.modelStr); - const modelId = parsed.model || entry.target.modelStr; - return entry.target.provider === selectedProvider && modelId === selectedModel; - })?.target || - rankedTargets[0] || - eligibleTargets[0]; - if (!selectedTarget) { - return unavailableResponse( - 429, - "No auto strategy targets remained after quota cutoff filtering" - ); - } - - // Keep eligibleTargets as the last-resort fallback tail: dedupe drops the - // routable ranked ones (and, when the cutoff is OFF, makes this identical to - // the pre-cutoff behavior), but a quota-blocked target still survives as a - // final fallback instead of vanishing — the hard cutoff only de-prioritizes. - orderedTargets = dedupeTargetsByExecutionKey( - [selectedTarget, ...rankedTargets, ...eligibleTargets].filter( - (entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null - ) - ); - - log.info( - "COMBO", - `Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}` - ); - } else { - log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering"); - } - } else if (strategy === "lkgp") { - try { - const { getLKGP } = await import("../../src/lib/localDb"); - const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name); - - if (lkgpProvider) { - const lkgpRecord = lkgpProvider; - const providerName = lkgpRecord.provider; - const connId = lkgpRecord.connectionId; - - let lkgpIndex = -1; - if (connId) { - lkgpIndex = orderedTargets.findIndex( - (target) => target.provider === providerName && target.connectionId === connId - ); - } - if (lkgpIndex < 0) { - lkgpIndex = orderedTargets.findIndex( - (target) => - target.provider === providerName || - // Issue #2359: Defensive guard. The `target.modelStr` type - // annotation is `string`, but malformed combo entries (e.g., - // local-provider rows whose `modelStr` failed to resolve when - // the executor catalogue was being rebuilt) have leaked - // through and surfaced as `e.startsWith is not a function` - // 500s on combo test/dispatch. The fast path stays - // unchanged for the common case; this only avoids the - // crash when the field is unexpectedly non-string. - (typeof target.modelStr === "string" && - target.modelStr.startsWith(`${providerName}/`)) - ); - } - - if (lkgpIndex > 0) { - const [lkgpTarget] = orderedTargets.splice(lkgpIndex, 1); - orderedTargets.unshift(lkgpTarget); - log.info( - "COMBO", - `[LKGP] Prioritizing last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} for combo "${combo.name}"` - ); - } else if (lkgpIndex === 0) { - log.debug?.( - "COMBO", - `[LKGP] Last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} already first for combo "${combo.name}"` - ); - } - } - } catch (err) { - log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err }); - } - } else if (strategy === "strict-random") { - const selectedExecutionKey = await getNextFromDeck( - `combo:${combo.name}`, - orderedTargets.map((target) => target.executionKey) - ); - const selectedTarget = - orderedTargets.find((target) => target.executionKey === selectedExecutionKey) || null; - // #3959: shuffle the fallback remainder too. Previously `rest` kept fixed - // priority order, so after a failing deck pick the chain always fell through - // to the same top-priority model — a persistently-failing model was retried - // on essentially every request and fallback load never spread across peers. - const rest = fisherYatesShuffle( - orderedTargets.filter((target) => target.executionKey !== selectedExecutionKey) - ); - orderedTargets = [selectedTarget, ...rest].filter( - (target): target is ResolvedComboTarget => target !== null - ); - log.info( - "COMBO", - `Strict-random deck: ${selectedExecutionKey} selected (${orderedTargets.length} targets)` - ); - } else if (strategy === "random") { - orderedTargets = fisherYatesShuffle([...orderedTargets]); - log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`); - } else if (strategy === "fill-first") { - log.info( - "COMBO", - `Fill-first ordering: preserving priority order (${orderedTargets.length} targets)` - ); - } else if (strategy === "p2c") { - orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name); - log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`); - } else if (strategy === "least-used") { - orderedTargets = sortTargetsByUsage(orderedTargets, combo.name); - log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`); - } else if (strategy === "cost-optimized") { - orderedTargets = await sortTargetsByCost(orderedTargets); - if (config.manifestRouting === true) { - try { - const manifestHint = generateRoutingHints( - orderedTargets.filter((t) => t.kind === "model"), - { - messages: Array.isArray(body?.messages) - ? (body.messages as Array<{ role?: string; content?: string | unknown }>) - : [], - tools: Array.isArray(body?.tools) - ? (body.tools as Array<{ - function?: { name: string; description?: string; parameters?: unknown }; - }>) - : undefined, - model: typeof body?.model === "string" ? body.model : undefined, - } - ); - if (manifestHint.strategyModifier === "require-premium") { - const eligible = orderedTargets.filter( - (t) => - t.kind !== "model" || - manifestHint.eligibleTargets.some( - (e) => e.provider === t.provider && e.modelStr === t.modelStr - ) - ); - if (eligible.length > 0) orderedTargets = eligible; - } - log.debug?.( - { - strategyModifier: manifestHint.strategyModifier, - specificityLevel: manifestHint.specificityLevel, - score: manifestHint.specificity.score, - }, - "manifest routing applied" - ); - } catch (err) { - log.warn({ err }, "manifest routing failed, falling back to standard strategy"); - } - } - log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`); - } else if (strategy === "reset-aware") { - orderedTargets = await orderTargetsByResetAwareQuota( + const autoResult = await resolveAutoStrategyOrder({ orderedTargets, - combo.name, + body, + combo, + settings, config, + relayOptions, + resilienceSettings, log, - apiKeyAllowedConnections - ); - log.info( - "COMBO", - `Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first` - ); - } else if (strategy === "reset-window") { - orderedTargets = await orderTargetsByResetWindow( - orderedTargets, - combo.name, + buildAutoCandidates, + }); + if ("earlyResponse" in autoResult) return autoResult.earlyResponse; + orderedTargets = autoResult.orderedTargets; + autoUsedExplicitRouter = autoResult.autoUsedExplicitRouter; + } else { + orderedTargets = await applyStrategyOrdering(strategy, orderedTargets, { + combo, config, + body, log, - apiKeyAllowedConnections - ); - log.info( - "COMBO", - `Reset-window ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first` - ); - } else if (strategy === "context-optimized") { - orderedTargets = sortTargetsByContextSize(orderedTargets); - log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`); - } else if (strategy === "headroom") { - orderedTargets = await orderTargetsByHeadroom( - orderedTargets, - combo.name, - log, - apiKeyAllowedConnections - ); - log.info( - "COMBO", - `Headroom ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} has most free capacity` - ); - } else if (strategy === "quota-share") { - // Internal quota-share combos (qtSd/): delegate to the dedicated module (DRR + - // P2C in-flight + per-model bucket gating + per-connection concurrency gating). - const qsModel = - typeof body?.model === "string" ? body.model : (orderedTargets[0]?.modelStr ?? ""); - const qsMaxConcurrent = await resolveMaxConcurrentByConnection(orderedTargets); - orderedTargets = selectQuotaShareTarget(orderedTargets, combo.name, qsModel, Date.now(), { - maxConcurrentByConnection: qsMaxConcurrent, - }).orderedTargets; - log.info( - "COMBO", - `Quota-share ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} selected (DRR+P2C)` - ); + apiKeyAllowedConnections, + }); } const _sticky = await applySessionStickiness( orderedTargets, @@ -1638,6 +1102,12 @@ export async function handleComboChat({ ) : new Map(); + // #5923 (Finding #4) — reset-window config for the shared per-target quota- + // exhaustion cutoff below. The "auto" strategy already applies its own cutoff + // via buildAutoCandidates/routableCandidates, so this only affects the other + // 16 strategies (priority, weighted, etc.) that funnel through executeTarget. + const quotaCutoffResetWindowConfig = resolveResetWindowConfig(config as Record); + if (orderedTargets.length === 0) { return comboModelNotFoundResponse("Combo has no executable targets"); } @@ -1790,6 +1260,33 @@ export async function handleComboChat({ return null; } + // #5923 (Finding #4) — honor the same opt-in quota-exhaustion cutoff the + // "auto" strategy already applies (buildAutoCandidates), for every other + // strategy (priority, weighted, etc.). Strictly scoped per (provider, + // connectionId): a 0%-remaining connection is skipped here, but sibling + // connections/models on the same provider are untouched — the provider + // circuit breaker is never touched by this check. The "auto" strategy is + // excluded to avoid a redundant duplicate fetch — it already filtered its + // candidate pool via `routableCandidates` before reaching this loop. + if (strategy !== "auto" && provider && target.connectionId) { + const quotaCutoff = await resolveQuotaExhaustionCutoffForTarget( + provider, + target.connectionId, + resilienceSettings, + quotaCutoffResetWindowConfig, + combo.name, + log + ); + if (quotaCutoff.blocked) { + log.info( + "COMBO", + `Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})` + ); + if (i > 0) fallbackCount++; + return null; + } + } + // Pre-screen snapshot is NOT used as a permanent skip — availability // is always re-checked via isModelAvailable below because connection // cooldowns can expire between setTry retries, making a previously @@ -2423,6 +1920,15 @@ export async function handleComboChat({ !isTokenLimitBreach && [408, 429, 500, 502, 503, 504].includes(result.status); if (retry < maxRetries && isTransient && !providerExhausted) { + if ( + provider && + rawModel && + isModelLocked(provider, targetWithConnection.connectionId || "", rawModel) + ) { + log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`); + if (i > 0) fallbackCount++; + return null; + } // Record model lockout immediately on the first transient failure — // once the model is cooling down, retrying it would waste an upstream // call and extend the cooldown via exponential backoff. diff --git a/open-sse/services/combo/__tests__/targetExhaustion.test.ts b/open-sse/services/combo/__tests__/targetExhaustion.test.ts deleted file mode 100644 index f422aafc6a..0000000000 --- a/open-sse/services/combo/__tests__/targetExhaustion.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { applyComboTargetExhaustion, type ComboExhaustionSets } from "../targetExhaustion.ts"; -import type { ResolvedComboTarget, ComboLogger } from "../types.ts"; - -function makeTarget(overrides: Partial = {}): ResolvedComboTarget { - return { - kind: "model", - stepId: "step-1", - executionKey: "key-1", - modelStr: "gpt-4", - provider: "openai", - providerId: "p1", - connectionId: "c1", - allowedConnectionIds: null, - weight: 1, - label: null, - failoverBeforeRetry: undefined, - ...overrides, - }; -} - -function makeLogger(): ComboLogger { - const msgs: string[] = []; - return { - info: (...args: unknown[]) => { msgs.push(args.join(" ")); }, - warn: (...args: unknown[]) => { msgs.push(args.join(" ")); }, - error: (...args: unknown[]) => { msgs.push(args.join(" ")); }, - debug: (...args: unknown[]) => { msgs.push(args.join(" ")); }, - _msgs: msgs, - } as ComboLogger & { _msgs: string[] }; -} - -function makeSets(): ComboExhaustionSets { - return { - exhaustedProviders: new Set(), - exhaustedConnections: new Set(), - transientRateLimitedProviders: new Set(), - }; -} - -describe("applyComboTargetExhaustion", () => { - it("marks provider exhausted when isProviderExhaustedReason is true (quota)", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 429 }, - // isProviderExhaustedReason reads `reason`/`creditsExhausted`/`dailyQuotaExhausted` - // (NOT `error.code`), so signal full-account exhaustion via creditsExhausted. - fallbackResult: { creditsExhausted: true }, - errorText: "", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(true); - expect(sets.exhaustedProviders.has("openai")).toBe(true); - expect(sets.exhaustedProviders.size).toBe(1); - expect(sets.transientRateLimitedProviders.has("openai")).toBe(false); - }); - - it("marks provider exhausted when classifyErrorText returns QUOTA_EXHAUSTED", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 429 }, - fallbackResult: {} as any, - // classifyErrorText flags "quota exceeded" as QUOTA_EXHAUSTED. - errorText: "Quota exceeded — please retry later.", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(true); - expect(sets.exhaustedProviders.has("openai")).toBe(true); - }); - - it("marks provider exhausted when allAccountsRateLimited is true", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 503 }, - fallbackResult: {} as any, - errorText: "Service temporarily unavailable", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: true, - sets, - log, - tag: "COMBO-RR", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(true); - expect(sets.exhaustedProviders.has("openai")).toBe(true); - }); - - it("does NOT mark provider exhausted for per-model-quota providers (different model)", () => { - const sets = makeSets(); - const log = makeLogger(); - // gemini has per-model quotas (hasPerModelQuota === true): a model-scoped quota - // 429 must NOT mark the whole provider exhausted — other models may still work. - const target = makeTarget({ provider: "gemini" }); - const exhausted = applyComboTargetExhaustion(target, { - result: { status: 429 }, - fallbackResult: { reason: "quota_exhausted" } as any, - errorText: "quota exceeded for model gpt-4", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.exhaustedProviders.has("gemini")).toBe(false); - expect(sets.transientRateLimitedProviders.has("gemini")).toBe(true); - }); - - it("does NOT mark provider exhausted for unknown providers", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget({ provider: "unknown" }), { - result: { status: 503 }, - fallbackResult: { error: { code: "quota_exhausted" } }, - errorText: "quota exhausted", - rawModel: "unknown-model", - isTokenLimitBreach: false, - allAccountsRateLimited: true, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - }); - - it("does NOT mark provider exhausted for empty provider strings", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget({ provider: "" }), { - result: { status: 503 }, - fallbackResult: { error: { code: "quota_exhausted" } }, - errorText: "quota exhausted", - rawModel: "model", - isTokenLimitBreach: false, - allAccountsRateLimited: true, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - }); - - it("marks transientRateLimited on 429 when NOT token-limit breach and NOT provider-exhausted", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 429 }, - fallbackResult: {} as any, - errorText: "Rate limited", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.transientRateLimitedProviders.has("openai")).toBe(true); - expect(sets.exhaustedProviders.has("openai")).toBe(false); - }); - - it("does NOT mark transientRateLimited on 429 when isTokenLimitBreach is true", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 429 }, - fallbackResult: {} as any, - errorText: "Token limit exceeded", - rawModel: "gpt-4", - isTokenLimitBreach: true, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.transientRateLimitedProviders.has("openai")).toBe(false); - expect(sets.exhaustedProviders.has("openai")).toBe(false); - }); - - it("marks exhaustedConnections on connection-level error status (502) with connectionId", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion( - makeTarget({ provider: "openai", connectionId: "conn-1" }), - { - result: { status: 502 }, - fallbackResult: {} as any, - errorText: "Bad Gateway", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - } - ); - expect(exhausted).toBe(false); - expect(sets.exhaustedConnections.has("openai:conn-1")).toBe(true); - expect(sets.exhaustedProviders.has("openai")).toBe(false); - }); - - it("marks exhaustedProviders on connection-level error when NO connectionId", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion( - makeTarget({ provider: "openai", connectionId: null }), - { - result: { status: 502 }, - fallbackResult: {} as any, - errorText: "Bad Gateway", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - } - ); - expect(exhausted).toBe(false); - expect(sets.exhaustedProviders.has("openai")).toBe(true); - expect(sets.exhaustedConnections.size).toBe(0); - }); - - it("does NOT mark anything for circuit-open (X-OmniRoute-Provider-Breaker header)", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 503, headers: new Map([["x-omniroute-provider-breaker", "open"]]) as any }, - fallbackResult: {} as any, - errorText: "", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.exhaustedProviders.has("openai")).toBe(false); - expect(sets.exhaustedConnections.has("openai:c1")).toBe(false); - expect(sets.transientRateLimitedProviders.has("openai")).toBe(false); - }); - - it("does NOT mark exhaustion for non-connection-level status codes (400)", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 400 }, - fallbackResult: {} as any, - errorText: "Bad Request", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.exhaustedConnections.size).toBe(0); - expect(sets.exhaustedProviders.size).toBe(0); - expect(sets.transientRateLimitedProviders.size).toBe(0); - }); - - it("does NOT mark anything for 200 (success)", () => { - const sets = makeSets(); - const log = makeLogger(); - const exhausted = applyComboTargetExhaustion(makeTarget(), { - result: { status: 200 }, - fallbackResult: {} as any, - errorText: "", - rawModel: "gpt-4", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); - expect(exhausted).toBe(false); - expect(sets.exhaustedProviders.size).toBe(0); - expect(sets.exhaustedConnections.size).toBe(0); - expect(sets.transientRateLimitedProviders.size).toBe(0); - }); -}); diff --git a/open-sse/services/combo/applyStrategyOrdering.ts b/open-sse/services/combo/applyStrategyOrdering.ts new file mode 100644 index 0000000000..e8cb39ecf8 --- /dev/null +++ b/open-sse/services/combo/applyStrategyOrdering.ts @@ -0,0 +1,226 @@ +import { fisherYatesShuffle, getNextFromDeck } from "../../../src/shared/utils/shuffleDeck"; +import { generateRoutingHints } from "../manifestAdapter"; +import { resolveMaxConcurrentByConnection } from "./concurrencyCaps.ts"; +import { sortTargetsByContextSize } from "./comboStructure.ts"; +import { selectQuotaShareTarget } from "./quotaShareStrategy.ts"; +import { + orderTargetsByHeadroom, + orderTargetsByResetAwareQuota, + orderTargetsByResetWindow, +} from "./quotaStrategies.ts"; +import { + orderTargetsByPowerOfTwoChoices, + sortTargetsByCost, + sortTargetsByUsage, +} from "./targetSorters.ts"; +import type { ComboLike, ComboLogger, ResolvedComboTarget } from "./types.ts"; + +export interface ApplyStrategyOrderingDeps { + combo: ComboLike; + config: Record; + body: Record; + log: ComboLogger; + apiKeyAllowedConnections: string[] | null; +} + +/** + * Apply the target-ordering step for every non-`auto` combo strategy. + * + * Extracted verbatim from the `else if (strategy === ...)` chain in + * handleComboChat (lkgp / strict-random / random / fill-first / p2c / + * least-used / cost-optimized / reset-aware / reset-window / context-optimized / + * headroom / quota-share). Each branch only reorders `orderedTargets` — no early + * returns, no other mutable state — so the extraction returns the reordered list. + * An unknown strategy falls through with the input order unchanged, matching the + * previous inline behavior (the chain had no trailing `else`). The `auto` strategy + * is handled separately by `resolveAutoStrategyOrder` and never reaches here. + */ +export async function applyStrategyOrdering( + strategy: string, + initialOrderedTargets: ResolvedComboTarget[], + deps: ApplyStrategyOrderingDeps +): Promise { + const { combo, config, body, log, apiKeyAllowedConnections } = deps; + let orderedTargets = initialOrderedTargets; + + if (strategy === "lkgp") { + try { + const { getLKGP } = await import("../../../src/lib/localDb"); + const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name); + + if (lkgpProvider) { + const lkgpRecord = lkgpProvider; + const providerName = lkgpRecord.provider; + const connId = lkgpRecord.connectionId; + + let lkgpIndex = -1; + if (connId) { + lkgpIndex = orderedTargets.findIndex( + (target) => target.provider === providerName && target.connectionId === connId + ); + } + if (lkgpIndex < 0) { + lkgpIndex = orderedTargets.findIndex( + (target) => + target.provider === providerName || + // Issue #2359: Defensive guard. The `target.modelStr` type + // annotation is `string`, but malformed combo entries (e.g., + // local-provider rows whose `modelStr` failed to resolve when + // the executor catalogue was being rebuilt) have leaked + // through and surfaced as `e.startsWith is not a function` + // 500s on combo test/dispatch. The fast path stays + // unchanged for the common case; this only avoids the + // crash when the field is unexpectedly non-string. + (typeof target.modelStr === "string" && + target.modelStr.startsWith(`${providerName}/`)) + ); + } + + if (lkgpIndex > 0) { + const [lkgpTarget] = orderedTargets.splice(lkgpIndex, 1); + orderedTargets.unshift(lkgpTarget); + log.info( + "COMBO", + `[LKGP] Prioritizing last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} for combo "${combo.name}"` + ); + } else if (lkgpIndex === 0) { + log.debug?.( + "COMBO", + `[LKGP] Last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} already first for combo "${combo.name}"` + ); + } + } + } catch (err) { + log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err }); + } + } else if (strategy === "strict-random") { + const selectedExecutionKey = await getNextFromDeck( + `combo:${combo.name}`, + orderedTargets.map((target) => target.executionKey) + ); + const selectedTarget = + orderedTargets.find((target) => target.executionKey === selectedExecutionKey) || null; + // #3959: shuffle the fallback remainder too. Previously `rest` kept fixed + // priority order, so after a failing deck pick the chain always fell through + // to the same top-priority model — a persistently-failing model was retried + // on essentially every request and fallback load never spread across peers. + const rest = fisherYatesShuffle( + orderedTargets.filter((target) => target.executionKey !== selectedExecutionKey) + ); + orderedTargets = [selectedTarget, ...rest].filter( + (target): target is ResolvedComboTarget => target !== null + ); + log.info( + "COMBO", + `Strict-random deck: ${selectedExecutionKey} selected (${orderedTargets.length} targets)` + ); + } else if (strategy === "random") { + orderedTargets = fisherYatesShuffle([...orderedTargets]); + log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`); + } else if (strategy === "fill-first") { + log.info( + "COMBO", + `Fill-first ordering: preserving priority order (${orderedTargets.length} targets)` + ); + } else if (strategy === "p2c") { + orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name); + log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`); + } else if (strategy === "least-used") { + orderedTargets = sortTargetsByUsage(orderedTargets, combo.name); + log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`); + } else if (strategy === "cost-optimized") { + orderedTargets = await sortTargetsByCost(orderedTargets); + if (config.manifestRouting === true) { + try { + const manifestHint = generateRoutingHints( + orderedTargets.filter((t) => t.kind === "model"), + { + messages: Array.isArray(body?.messages) + ? (body.messages as Array<{ role?: string; content?: string | unknown }>) + : [], + tools: Array.isArray(body?.tools) + ? (body.tools as Array<{ + function?: { name: string; description?: string; parameters?: unknown }; + }>) + : undefined, + model: typeof body?.model === "string" ? body.model : undefined, + } + ); + if (manifestHint.strategyModifier === "require-premium") { + const eligible = orderedTargets.filter( + (t) => + t.kind !== "model" || + manifestHint.eligibleTargets.some( + (e) => e.provider === t.provider && e.modelStr === t.modelStr + ) + ); + if (eligible.length > 0) orderedTargets = eligible; + } + log.debug?.( + { + strategyModifier: manifestHint.strategyModifier, + specificityLevel: manifestHint.specificityLevel, + score: manifestHint.specificity.score, + }, + "manifest routing applied" + ); + } catch (err) { + log.warn({ err }, "manifest routing failed, falling back to standard strategy"); + } + } + log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`); + } else if (strategy === "reset-aware") { + orderedTargets = await orderTargetsByResetAwareQuota( + orderedTargets, + combo.name, + config, + log, + apiKeyAllowedConnections + ); + log.info( + "COMBO", + `Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first` + ); + } else if (strategy === "reset-window") { + orderedTargets = await orderTargetsByResetWindow( + orderedTargets, + combo.name, + config, + log, + apiKeyAllowedConnections + ); + log.info( + "COMBO", + `Reset-window ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first` + ); + } else if (strategy === "context-optimized") { + orderedTargets = sortTargetsByContextSize(orderedTargets); + log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`); + } else if (strategy === "headroom") { + orderedTargets = await orderTargetsByHeadroom( + orderedTargets, + combo.name, + log, + apiKeyAllowedConnections + ); + log.info( + "COMBO", + `Headroom ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} has most free capacity` + ); + } else if (strategy === "quota-share") { + // Internal quota-share combos (qtSd/): delegate to the dedicated module (DRR + + // P2C in-flight + per-model bucket gating + per-connection concurrency gating). + const qsModel = + typeof body?.model === "string" ? body.model : (orderedTargets[0]?.modelStr ?? ""); + const qsMaxConcurrent = await resolveMaxConcurrentByConnection(orderedTargets); + orderedTargets = selectQuotaShareTarget(orderedTargets, combo.name, qsModel, Date.now(), { + maxConcurrentByConnection: qsMaxConcurrent, + }).orderedTargets; + log.info( + "COMBO", + `Quota-share ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} selected (DRR+P2C)` + ); + } + + return orderedTargets; +} diff --git a/open-sse/services/combo/autoConfig.ts b/open-sse/services/combo/autoConfig.ts new file mode 100644 index 0000000000..c79c9ad032 --- /dev/null +++ b/open-sse/services/combo/autoConfig.ts @@ -0,0 +1,62 @@ +import { DEFAULT_WEIGHTS, type ScoringWeights } from "../autoCombo/scoring.ts"; +import { isRecord } from "./comboData.ts"; +import { resolveResetWindowConfig, resolveSlaRoutingPolicy } from "./quotaScoring.ts"; +import type { ComboLike, ResolvedComboTarget } from "./types.ts"; + +/** + * Resolve the auto-strategy routing configuration for a combo. + * + * Pure function of `(combo, eligibleTargets)`: derives the router strategy name, + * candidate provider pool, scoring weights, exploration rate, budget cap, mode + * pack, reset-window config and SLA policy from the combo's `autoConfig`/`config`. + * No side effects, no early returns — extracted verbatim from `handleComboChat` + * so its behavior is byte-identical to the previous inline block. + */ +export function parseAutoConfig(combo: ComboLike, eligibleTargets: ResolvedComboTarget[]) { + const rawAutoConfigSource = + combo?.autoConfig || + (isRecord(combo?.config?.auto) ? combo.config.auto : null) || + combo?.config || + {}; + const autoConfigSource: Record = isRecord(rawAutoConfigSource) + ? rawAutoConfigSource + : {}; + const routingStrategy = + typeof autoConfigSource.routerStrategy === "string" + ? autoConfigSource.routerStrategy + : typeof autoConfigSource.routingStrategy === "string" + ? autoConfigSource.routingStrategy + : typeof autoConfigSource.strategyName === "string" + ? autoConfigSource.strategyName + : "rules"; + + const candidatePool = Array.isArray(autoConfigSource.candidatePool) + ? autoConfigSource.candidatePool + : [...new Set(eligibleTargets.map((target) => target.provider))]; + + const weights = + autoConfigSource.weights && typeof autoConfigSource.weights === "object" + ? (autoConfigSource.weights as ScoringWeights) + : DEFAULT_WEIGHTS; + const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate)) + ? Number(autoConfigSource.explorationRate) + : 0.05; + const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap)) + ? Number(autoConfigSource.budgetCap) + : undefined; + const modePack = + typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined; + const resetWindowConfig = resolveResetWindowConfig(autoConfigSource); + const slaPolicy = resolveSlaRoutingPolicy(autoConfigSource); + + return { + routingStrategy, + candidatePool, + weights, + explorationRate, + budgetCap, + modePack, + resetWindowConfig, + slaPolicy, + }; +} diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts new file mode 100644 index 0000000000..e0b75c1873 --- /dev/null +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -0,0 +1,140 @@ +/** + * Quota-exhaustion cutoff helpers for combo routing. + * + * Home of the opt-in per-(provider, connection, window) quota-exhaustion cutoff + * shared by the "auto" strategy candidate builder (`buildAutoQuotaThresholds`, + * consumed by combo.ts::buildAutoCandidates) and the per-target eligibility loop + * (`resolveQuotaExhaustionCutoffForTarget`, consumed by combo.ts::handleComboChat + * for every non-auto strategy). Extracted from combo.ts (#5923 Finding #4) to + * keep the god-file under its frozen size cap; behavior is byte-identical. + * + * Pure leaf: this module never imports from the combo barrel. Threshold math and + * cutoff evaluation are delegated to ./quotaPreflight.ts; the reset-aware quota + * fetch/cache is delegated to ./quotaStrategies.ts. + */ + +import { + evaluateQuotaCutoff, + getQuotaFetcher, + type PreflightQuotaThresholds, + type QuotaInfo, +} from "../quotaPreflight.ts"; +import { getProviderConnectionById } from "../../../src/lib/db/providers"; +import { + resolveResilienceSettings, + type ResilienceSettings, +} from "../../../src/lib/resilience/settings"; +import { fetchResetAwareQuotaWithCache } from "./quotaStrategies.ts"; +import type { ResetWindowConfig } from "./quotaScoring.ts"; + +function asThresholdMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + const numeric = Number(raw); + if (key && Number.isFinite(numeric)) result[key] = numeric; + } + return result; +} + +function quotaWindowLookupNames(provider: string, windowName: string): string[] { + const names = [windowName]; + const lower = windowName.toLowerCase(); + if (lower !== windowName) names.push(lower); + if (provider === "codex") { + if (lower.includes("session") || lower === "5h" || lower === "five_hour") names.push("session"); + if (lower.includes("weekly") || lower === "7d" || lower === "seven_day") names.push("weekly"); + if (lower.includes("monthly") || lower === "30d") names.push("monthly"); + } + return [...new Set(names)]; +} + +export function buildAutoQuotaThresholds( + provider: string, + connection: Record | undefined, + resilienceSettings: ResilienceSettings | null | undefined +): PreflightQuotaThresholds { + const quotaPreflight = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight; + const defaultThresholdPercent = quotaPreflight?.defaultThresholdPercent ?? 2; + const warnThresholdPercent = quotaPreflight?.warnThresholdPercent ?? 20; + const providerWindowMap = asThresholdMap(quotaPreflight?.providerWindowDefaults?.[provider]); + const perConnectionWindowOverrides = asThresholdMap(connection?.quotaWindowThresholds); + + return { + resolveMinRemainingPercent: (windowName: string | null): number => { + if (windowName !== null) { + for (const lookupWindowName of quotaWindowLookupNames(provider, windowName)) { + const override = perConnectionWindowOverrides[lookupWindowName]; + if (typeof override === "number") return override; + const providerDefault = providerWindowMap[lookupWindowName]; + if (typeof providerDefault === "number") return providerDefault; + } + } + return defaultThresholdPercent; + }, + resolveWarnRemainingPercent: () => warnThresholdPercent, + }; +} + +/** + * #5923 (Finding #4) — Shared quota-exhaustion cutoff predicate, scoped strictly + * per (provider, connectionId, model window). Extracted from the inline logic + * that `buildAutoCandidates` has always used (fetch via the SAME + * `fetchResetAwareQuotaWithCache` cache, evaluate via the SAME pure + * `evaluateQuotaCutoff` + `buildAutoQuotaThresholds`), so priority/weighted/etc. + * strategies honor the operator's configured quota cutoff instead of only the + * "auto" strategy. + * + * Gated behind the SAME opt-in setting as the auto-strategy cutoff + * (`resilienceSettings.quotaPreflight.enabled`) — when that setting is off this + * is a no-op, exactly like the auto path. Never touches the provider circuit + * breaker; a blocked result only means "skip this one connection", leaving + * every sibling connection/model for the same provider fully eligible. + */ +export async function resolveQuotaExhaustionCutoffForTarget( + provider: string, + connectionId: string | undefined, + resilienceSettings: ResilienceSettings | null | undefined, + resetWindowConfig: ResetWindowConfig, + comboName: string, + log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } +): Promise<{ blocked: boolean; reason?: string }> { + const quotaCutoffEnabled = + (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true; + if (!quotaCutoffEnabled || !provider || !connectionId) return { blocked: false }; + + const fetcher = getQuotaFetcher(provider); + if (!fetcher) return { blocked: false }; + + let connection: Record | undefined; + try { + connection = (await getProviderConnectionById(connectionId)) as + | Record + | undefined; + } catch { + connection = undefined; + } + + try { + const quota = await fetchResetAwareQuotaWithCache({ + provider, + connectionId, + connection, + fetcher, + config: resetWindowConfig, + log, + comboName, + }); + const cutoffDecision = evaluateQuotaCutoff( + quota as QuotaInfo | null, + buildAutoQuotaThresholds(provider, connection, resilienceSettings) + ); + if (!cutoffDecision.proceed) { + return { blocked: true, reason: cutoffDecision.reason || "quota_exhausted" }; + } + } catch { + // Fail-open: never block routing because the preflight fetch itself errored. + return { blocked: false }; + } + return { blocked: false }; +} diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts new file mode 100644 index 0000000000..980d922b27 --- /dev/null +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -0,0 +1,306 @@ +import { unavailableResponse } from "../../utils/error.ts"; +import { selectProvider as selectAutoProvider } from "../autoCombo/engine.ts"; +import { selectWithStrategy } from "../autoCombo/routerStrategy.ts"; +import { buildComplexityRoutingHint } from "../autoCombo/complexityRouter"; +import { recordComboIntent } from "../comboMetrics.ts"; +import { estimateTokens } from "../contextManager.ts"; +import { classifyWithConfig } from "../intentClassifier.ts"; +import type { RoutingHint } from "../manifestAdapter"; +import { parseModel } from "../model.ts"; +import { supportsToolCalling } from "../modelCapabilities.ts"; +import type { ResilienceSettings } from "../../../src/lib/resilience/settings"; +import { parseAutoConfig } from "./autoConfig.ts"; +import { dedupeTargetsByExecutionKey } from "./comboData.ts"; +import { getModelContextLimitForModelString } from "./comboStructure.ts"; +import type { ResetWindowConfig } from "./quotaScoring.ts"; +import { + _registerExecutionCandidates, + expandAutoComboCandidatePool, + extractPromptForIntent, + getIntentConfig, + mapIntentToTaskType, + scoreAutoTargets, +} from "./autoStrategy.ts"; +import type { + AutoProviderCandidate, + ComboLike, + ComboLogger, + ResolvedComboTarget, +} from "./types.ts"; + +/** + * Dependency-injected `buildAutoCandidates` — it lives in `combo.ts` (the host of + * this leaf), so importing it directly would create an import cycle. Passing it + * through `deps` keeps this module acyclic (same pattern as `buildTargetTimeoutRunner`). + */ +type BuildAutoCandidates = ( + targets: ResolvedComboTarget[], + comboName: string, + sessionId?: string | null, + resetWindowConfig?: ResetWindowConfig, + resilienceSettings?: ResilienceSettings | null +) => Promise; + +export interface ResolveAutoStrategyDeps { + orderedTargets: ResolvedComboTarget[]; + body: Record; + combo: ComboLike; + settings: Record | null | undefined; + config: { complexityAwareRouting?: boolean }; + relayOptions?: { bypassProviderQuotaPolicy?: boolean; sessionId?: string | null } | null; + resilienceSettings: ResilienceSettings; + log: ComboLogger; + buildAutoCandidates: BuildAutoCandidates; +} + +export type ResolveAutoStrategyResult = + | { earlyResponse: Response } + | { orderedTargets: ResolvedComboTarget[]; autoUsedExplicitRouter: boolean }; + +/** + * Resolve target ordering for the `auto` combo strategy. + * + * Extracted verbatim from `handleComboChat`'s `if (strategy === "auto")` branch: + * tool-calling + context-window pre-filters, intent classification, candidate + * building (quota cutoff), explicit-router vs rules selection, complexity-aware + * scoring and final dedup ordering. Behavior is byte-identical to the previous + * inline block; the two `return unavailableResponse(...)` exits become + * `{ earlyResponse }` so the host can decide to return them, and the mutated + * `orderedTargets` / `autoUsedExplicitRouter` are returned instead of closed over. + */ +export async function resolveAutoStrategyOrder( + deps: ResolveAutoStrategyDeps +): Promise { + const { + body, + combo, + settings, + config, + relayOptions, + resilienceSettings, + log, + buildAutoCandidates, + } = deps; + let orderedTargets = deps.orderedTargets; + let autoUsedExplicitRouter = false; + + const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0; + let eligibleTargets = [...orderedTargets]; + + if (requestHasTools) { + const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr)); + if (filtered.length > 0) { + eligibleTargets = filtered; + } else { + log.warn( + "COMBO", + "Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool" + ); + } + } + + // Context-window pre-filter (#1808) + // Estimate input tokens once; exclude candidates whose known context limit is too small. + // Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext(). + // Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets. + const requestMessages = body.messages; + const estimatedInputTokens = estimateTokens( + typeof requestMessages === "string" || + (requestMessages !== null && typeof requestMessages === "object") + ? requestMessages + : [] + ); + if (estimatedInputTokens > 0) { + const filteredByContext = eligibleTargets.filter((target) => { + const limit = getModelContextLimitForModelString(target.modelStr); + if (limit === null || limit === undefined) return true; // unknown — include to be safe + return limit >= estimatedInputTokens; + }); + if (filteredByContext.length > 0) { + log.debug?.( + "COMBO", + `Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)` + ); + eligibleTargets = filteredByContext; + } else { + log.warn( + "COMBO", + `Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool` + ); + // eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter + } + + eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo); + } + + const prompt = extractPromptForIntent(body); + const systemPrompt = typeof combo?.system_message === "string" ? combo.system_message : undefined; + const intentConfig = getIntentConfig(settings, combo); + const intent = classifyWithConfig(prompt, intentConfig, systemPrompt); + recordComboIntent(combo.name, intent); + const taskType = mapIntentToTaskType(intent); + + const { + routingStrategy, + candidatePool, + weights, + explorationRate, + budgetCap, + modePack, + resetWindowConfig, + slaPolicy, + } = parseAutoConfig(combo, eligibleTargets); + + let lastKnownGoodProvider: string | undefined; + try { + const { getLKGP } = await import("../../../src/lib/localDb"); + const lkgp = await getLKGP(combo.name, combo.id || combo.name); + if (lkgp) lastKnownGoodProvider = lkgp.provider; + } catch (err) { + log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err }); + } + + const autoCandidateResilienceSettings = + relayOptions?.bypassProviderQuotaPolicy === true + ? { + ...resilienceSettings, + quotaPreflight: { + ...resilienceSettings.quotaPreflight, + enabled: false, + }, + } + : resilienceSettings; + const candidates = await buildAutoCandidates( + eligibleTargets, + combo.name, + relayOptions?.sessionId, + resetWindowConfig, + autoCandidateResilienceSettings + ); + const routableCandidates = candidates.filter( + (candidate) => candidate.quotaCutoffBlocked !== true + ); + const quotaBlockedCount = candidates.length - routableCandidates.length; + if (quotaBlockedCount > 0) { + log.info( + "COMBO", + `Auto strategy: quota cutoff skipped ${quotaBlockedCount}/${candidates.length} account candidates` + ); + } + // G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty. + _registerExecutionCandidates(routableCandidates); + if (candidates.length > 0 && routableCandidates.length === 0) { + return { + earlyResponse: unavailableResponse( + 429, + "All auto strategy candidates are below configured quota cutoffs" + ), + }; + } + if (routableCandidates.length > 0) { + let selectedProvider: string | null = null; + let selectedModel: string | null = null; + let selectionReason = ""; + + if (routingStrategy !== "rules") { + try { + const decision = selectWithStrategy( + routableCandidates, + { + taskType, + requestHasTools, + lastKnownGoodProvider, + estimatedInputTokens, + sla: slaPolicy, + }, + routingStrategy + ); + selectedProvider = decision.provider; + selectedModel = decision.model; + selectionReason = decision.reason; + autoUsedExplicitRouter = true; + } catch (err) { + log.warn( + "COMBO", + `Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules` + ); + } + } + + if (!selectedProvider || !selectedModel) { + const selection = selectAutoProvider( + { + id: combo.id || combo.name, + name: combo.name, + type: "auto", + candidatePool, + weights, + modePack, + budgetCap, + explorationRate, + }, + routableCandidates, + taskType + ); + selectedProvider = selection.provider; + selectedModel = selection.model; + selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`; + } + + // Complexity-aware routing (2026, opt-in): classify the request's + // difficulty and feed a tier hint into scoring so tierAffinity / + // specificityMatch favor candidates whose tier matches the request. + const autoManifestHint: RoutingHint | null = + config.complexityAwareRouting === true + ? buildComplexityRoutingHint( + eligibleTargets.filter((t) => t.kind === "model"), + body, + log + ) + : null; + + const scoredTargets = scoreAutoTargets( + eligibleTargets, + routableCandidates, + taskType, + weights, + autoManifestHint + ); + const rankedTargets = scoredTargets.map((entry) => entry.target); + const selectedTarget = + scoredTargets.find((entry) => { + const parsed = parseModel(entry.target.modelStr); + const modelId = parsed.model || entry.target.modelStr; + return entry.target.provider === selectedProvider && modelId === selectedModel; + })?.target || + rankedTargets[0] || + eligibleTargets[0]; + if (!selectedTarget) { + return { + earlyResponse: unavailableResponse( + 429, + "No auto strategy targets remained after quota cutoff filtering" + ), + }; + } + + // Keep eligibleTargets as the last-resort fallback tail: dedupe drops the + // routable ranked ones (and, when the cutoff is OFF, makes this identical to + // the pre-cutoff behavior), but a quota-blocked target still survives as a + // final fallback instead of vanishing — the hard cutoff only de-prioritizes. + orderedTargets = dedupeTargetsByExecutionKey( + [selectedTarget, ...rankedTargets, ...eligibleTargets].filter( + (entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null + ) + ); + + log.info( + "COMBO", + `Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}` + ); + } else { + log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering"); + } + + return { orderedTargets, autoUsedExplicitRouter }; +} diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 01f73d58db..7091b483ef 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -104,7 +104,7 @@ export function applyComboTargetExhaustion( if (result.status === 429 && !isTokenLimitBreach && provider && provider !== "unknown") { transientRateLimitedProviders.add(provider); } - markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag }); + markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag, rawModel }); } return providerExhausted; @@ -118,9 +118,12 @@ export function applyComboTargetExhaustion( */ function markConnectionLevelExhaustion( target: ResolvedComboTarget, - opts: Pick + opts: Pick< + ApplyComboTargetExhaustionOptions, + "result" | "errorText" | "sets" | "log" | "tag" | "rawModel" + > ): void { - const { result, errorText, sets, log, tag } = opts; + const { result, errorText, sets, log, tag, rawModel } = opts; const provider = target.provider; if ( !provider || @@ -130,7 +133,13 @@ function markConnectionLevelExhaustion( // #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) + isEmptyContentFailure(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. + // Other connection-level statuses (408/502/503/504/524) indicate the connection itself is + // bad, so they correctly exhaust even for per-model-quota providers. + (result.status === 500 && hasPerModelQuota(provider, rawModel)) ) { return; } diff --git a/open-sse/services/combo/targetTimeoutRunner.ts b/open-sse/services/combo/targetTimeoutRunner.ts new file mode 100644 index 0000000000..a1479b8e07 --- /dev/null +++ b/open-sse/services/combo/targetTimeoutRunner.ts @@ -0,0 +1,91 @@ +/** + * Wrap a single-model dispatch with a per-target timeout that aborts and falls back. + * + * Verbatim extraction of handleComboChat's `handleSingleModelWithTimeout` closure + * (combo.ts). Behavior is byte-identical; the only change is that the closed-over locals + * (`handleSingleModel`, `comboTargetTimeoutMs`, `log`) became explicit factory params. + * The per-model abort signal still comes from the target (`target.modelAbortSignal`), so + * the outer request signal is intentionally NOT a dependency here. + * + * See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1). + */ +import { errorResponse } from "../../utils/error.ts"; +import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts"; + +export function buildTargetTimeoutRunner(deps: { + handleSingleModel: HandleSingleModel; + comboTargetTimeoutMs: number; + log: ComboLogger; +}): ( + b: Record, + modelStr: string, + target?: SingleModelTarget +) => Promise { + const { handleSingleModel, comboTargetTimeoutMs, log } = deps; + return async ( + b: Record, + modelStr: string, + target?: SingleModelTarget + ): Promise => { + if (comboTargetTimeoutMs <= 0) { + return handleSingleModel(b, modelStr, target).catch((err) => + errorResponse(502, err?.message ?? "Upstream model error") + ); + } + + const timeoutController = new AbortController(); + let timeoutId: ReturnType | undefined; + let timedOut = false; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + timedOut = true; + log.warn( + "COMBO", + `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` + ); + timeoutController.abort(new Error("combo-per-model-timeout")); + resolve( + new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), { + status: 524, + headers: { "Content-Type": "application/json" }, + }) + ); + }, comboTargetTimeoutMs); + }); + const targetWithSignal = { + ...(target ?? {}), + modelAbortSignal: timeoutController.signal, + }; + const parentHedgeSignal = target?.modelAbortSignal ?? null; + let onParentHedgeAbort: (() => void) | null = null; + if (parentHedgeSignal) { + if (parentHedgeSignal.aborted) { + timeoutController.abort(new Error("hedge-cancelled")); + } else { + onParentHedgeAbort = () => { + timeoutController.abort(new Error("hedge-cancelled")); + }; + parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true }); + } + } + try { + return await Promise.race([ + handleSingleModel(b, modelStr, targetWithSignal).catch((err) => { + if (timedOut) { + // Inner call rejected because we aborted it. The synthetic 524 from + // timeoutPromise already wins the race; return an empty response so + // the loser branch resolves cleanly without leaking err.message. + return new Response(null, { status: 599 }); + } + return errorResponse(502, err?.message ?? "Upstream model error"); + }), + timeoutPromise, + ]); + } finally { + clearTimeout(timeoutId); + if (parentHedgeSignal && onParentHedgeAbort) { + parentHedgeSignal.removeEventListener("abort", onParentHedgeAbort); + } + } + }; +} diff --git a/open-sse/services/deviceTracker.ts b/open-sse/services/deviceTracker.ts new file mode 100644 index 0000000000..84d6985f57 --- /dev/null +++ b/open-sse/services/deviceTracker.ts @@ -0,0 +1,307 @@ +/** + * Per-API-Key Device Tracker + * + * Tracks unique client "devices" (IP + User-Agent fingerprint) that have used + * a given API key, so operators can see how many distinct connections are + * active behind a key — independent of `maxSessions` (which caps concurrent + * sticky-routing sessions, not device identity; see `sessionManager.ts`). + * + * In-memory only, module-scoped Map (same pattern as `sessionManager.ts` — + * no `global.*` singleton). Records never store the raw IP: it is masked + * before being written, so even a memory dump or the `/api/keys/[id]/devices` + * endpoint can't leak a full client IP. + * + * Ported from upstream 9router#931 (thanks @mugnimaestra) — original stored + * a global singleton keyed by the raw API key string; this port keys by + * `apiKeyInfo.id` (OmniRoute never threads the raw key value down to + * `chatCore`) and follows the module-Map + `unref()` cleanup-timer pattern + * used across `open-sse/services/`. + */ + +import { createHash } from "node:crypto"; + +const DEFAULT_TTL_MS = 30 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 60 * 1000; +const DEFAULT_MAX_DEVICES_PER_API_KEY = 1000; +const DEFAULT_MAX_TOTAL_DEVICES = 10000; +const MAX_STORED_USER_AGENT_LENGTH = 256; + +const TTL_ENV_NAME = "DEVICE_TRACKER_TTL_MS"; +const MAX_PER_KEY_ENV_NAME = "DEVICE_TRACKER_MAX_DEVICES_PER_KEY"; +const MAX_TOTAL_ENV_NAME = "DEVICE_TRACKER_MAX_TOTAL_DEVICES"; + +interface DeviceRecord { + fingerprint: string; + /** Already masked — never the raw client IP. */ + ip: string; + /** Truncated to MAX_STORED_USER_AGENT_LENGTH. */ + userAgent: string; + lastSeen: number; +} + +export interface DeviceDetail { + /** Truncated fingerprint (first 12 hex chars) — never the full hash. */ + fingerprint: string; + ip: string; + userAgent: string; + lastSeen: number; +} + +function parseTtlMs(): number { + const rawValue = process.env[TTL_ENV_NAME]; + if (!rawValue) return DEFAULT_TTL_MS; + const parsedValue = Number(rawValue); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) return DEFAULT_TTL_MS; + return parsedValue; +} + +function parsePositiveIntegerEnv(envName: string, defaultValue: number): number { + const rawValue = process.env[envName]; + if (!rawValue) return defaultValue; + const parsedValue = Number(rawValue); + if (!Number.isInteger(parsedValue) || parsedValue <= 0) return defaultValue; + return parsedValue; +} + +let ttlMs = parseTtlMs(); +let maxDevicesPerApiKey = parsePositiveIntegerEnv( + MAX_PER_KEY_ENV_NAME, + DEFAULT_MAX_DEVICES_PER_API_KEY +); +let maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES); + +// Module-scoped in-memory store — mirrors the `sessionManager.ts` pattern. +// key: apiKeyId → Map +const devicesByApiKey = new Map>(); + +let cleanupTimer: ReturnType | null = null; + +/** + * Mask an IP address so the stored/reported value never reveals the full + * client address. IPv4 keeps the first two octets; IPv6 keeps the first + * three groups. + */ +export function maskIp(ip: string | null | undefined): string { + if (!ip || ip === "unknown") return "unknown"; + + const ipv4Parts = ip.split("."); + if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d{1,3}$/.test(part))) { + return `${ipv4Parts[0]}.${ipv4Parts[1]}.x.x`; + } + + if (ip.includes(":")) { + const visibleGroups = ip.split(":").filter(Boolean).slice(0, 3).join(":"); + return visibleGroups ? `${visibleGroups}:...` : "unknown"; + } + + return "masked"; +} + +function truncateUserAgent(userAgent: string): string { + if (userAgent.length <= MAX_STORED_USER_AGENT_LENGTH) return userAgent; + return `${userAgent.slice(0, MAX_STORED_USER_AGENT_LENGTH)}...`; +} + +function createFingerprint(ip: string, userAgent: string): string { + return createHash("sha256").update(`${ip}|${userAgent}`).digest("hex"); +} + +function getTotalDeviceCount(): number { + let count = 0; + for (const devices of devicesByApiKey.values()) count += devices.size; + return count; +} + +function deleteDevice(apiKeyId: string, fingerprint: string): boolean { + const devices = devicesByApiKey.get(apiKeyId); + if (!devices) return false; + const deleted = devices.delete(fingerprint); + if (devices.size === 0) devicesByApiKey.delete(apiKeyId); + return deleted; +} + +function findOldestDevice( + apiKeyId: string | null +): { apiKeyId: string; fingerprint: string; lastSeen: number } | null { + let oldest: { apiKeyId: string; fingerprint: string; lastSeen: number } | null = null; + const entries = apiKeyId ? [[apiKeyId, devicesByApiKey.get(apiKeyId)] as const] : devicesByApiKey.entries(); + + for (const [entryApiKeyId, devices] of entries) { + if (!devices) continue; + for (const [fingerprint, record] of devices.entries()) { + if (!oldest || record.lastSeen < oldest.lastSeen) { + oldest = { apiKeyId: entryApiKeyId, fingerprint, lastSeen: record.lastSeen }; + } + } + } + + return oldest; +} + +function evictOldestDevice(apiKeyId: string | null = null): boolean { + const oldest = findOldestDevice(apiKeyId); + if (!oldest) return false; + return deleteDevice(oldest.apiKeyId, oldest.fingerprint); +} + +function enforceDeviceLimits(apiKeyId: string, devices: Map): void { + while (devices.size >= maxDevicesPerApiKey) { + if (!evictOldestDevice(apiKeyId)) break; + } + while (getTotalDeviceCount() >= maxTotalDevices) { + if (!evictOldestDevice()) break; + } +} + +/** + * Remove expired device records. Exported for tests; the cleanup timer + * calls this on an interval in production. + */ +export function expireDevices(now: number = Date.now()): number { + let expiredCount = 0; + + for (const [apiKeyId, devices] of devicesByApiKey.entries()) { + for (const [fingerprint, record] of devices.entries()) { + if (now - record.lastSeen > ttlMs) { + devices.delete(fingerprint); + expiredCount += 1; + } + } + if (devices.size === 0) devicesByApiKey.delete(apiKeyId); + } + + return expiredCount; +} + +function ensureCleanupTimer(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + expireDevices(); + }, CLEANUP_INTERVAL_MS); + cleanupTimer.unref?.(); +} + +ensureCleanupTimer(); + +/** + * Extract the client IP from a header source. Mirrors the priority order + * already used across `open-sse/` (`cf-connecting-ip` → `x-real-ip` → + * `x-forwarded-for`, first hop only). Returns "unknown" when absent. + */ +export function extractIpFromHeaders( + headers: Record | Headers | null | undefined +): string { + if (!headers) return "unknown"; + + const getHeader = (name: string): string | null => { + if (headers instanceof Headers) return headers.get(name); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name && typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; + }; + + const edgeIp = + getHeader("cf-connecting-ip") || getHeader("x-real-ip") || getHeader("fastly-client-ip"); + if (edgeIp) return edgeIp; + + const forwardedFor = getHeader("x-forwarded-for"); + if (forwardedFor) { + const firstIp = forwardedFor.split(",")[0]?.trim(); + if (firstIp) return firstIp; + } + + return "unknown"; +} + +/** + * Track a device (IP + User-Agent fingerprint) for an API key. Idempotent — + * calling it again for the same key + fingerprint just refreshes `lastSeen`. + * No-ops (returns null) when `apiKeyId` is missing, so callers can call it + * unconditionally after resolving `apiKeyInfo`. + */ +export function trackDevice( + apiKeyId: string | null | undefined, + ip: string | null | undefined, + userAgent: string | null | undefined +): string | null { + if (!apiKeyId || typeof apiKeyId !== "string") return null; + + const now = Date.now(); + expireDevices(now); + + const resolvedIp = ip && ip.trim() ? ip.trim() : "unknown"; + const resolvedUserAgent = userAgent && userAgent.trim() ? userAgent.trim() : "unknown"; + const fingerprint = createFingerprint(resolvedIp, resolvedUserAgent); + + let devices = devicesByApiKey.get(apiKeyId); + if (!devices) { + devices = new Map(); + devicesByApiKey.set(apiKeyId, devices); + } + + const existingRecord = devices.get(fingerprint); + if (existingRecord) { + existingRecord.lastSeen = now; + } else { + enforceDeviceLimits(apiKeyId, devices); + if (!devicesByApiKey.has(apiKeyId)) devicesByApiKey.set(apiKeyId, devices); + devices.set(fingerprint, { + fingerprint, + ip: maskIp(resolvedIp), + userAgent: truncateUserAgent(resolvedUserAgent), + lastSeen: now, + }); + } + + return fingerprint; +} + +/** Number of distinct devices currently tracked for an API key. */ +export function getDeviceCount(apiKeyId: string | null | undefined): number { + expireDevices(); + if (!apiKeyId || typeof apiKeyId !== "string") return 0; + return devicesByApiKey.get(apiKeyId)?.size || 0; +} + +/** Device detail rows for an API key — masked IP, truncated fingerprint. */ +export function getDeviceDetails(apiKeyId: string | null | undefined): DeviceDetail[] { + expireDevices(); + if (!apiKeyId || typeof apiKeyId !== "string") return []; + + const devices = devicesByApiKey.get(apiKeyId); + if (!devices) return []; + + return Array.from(devices.values()).map((record) => ({ + fingerprint: record.fingerprint.slice(0, 12), + ip: record.ip, + userAgent: record.userAgent, + lastSeen: record.lastSeen, + })); +} + +/** Device counts for every tracked API key. */ +export function getAllDeviceCounts(): Record { + expireDevices(); + const counts: Record = {}; + for (const [apiKeyId, devices] of devicesByApiKey.entries()) { + counts[apiKeyId] = devices.size; + } + return counts; +} + +/** + * Test-only reset — mirrors `sessionManager.ts::clearSessions()`. Also lets + * tests override the TTL/limit env vars deterministically. + */ +export function clearDeviceTracker(): void { + devicesByApiKey.clear(); + ttlMs = parseTtlMs(); + maxDevicesPerApiKey = parsePositiveIntegerEnv( + MAX_PER_KEY_ENV_NAME, + DEFAULT_MAX_DEVICES_PER_API_KEY + ); + maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES); +} diff --git a/open-sse/services/hfModelSuggestions.ts b/open-sse/services/hfModelSuggestions.ts new file mode 100644 index 0000000000..09a273a687 --- /dev/null +++ b/open-sse/services/hfModelSuggestions.ts @@ -0,0 +1,63 @@ +/** + * HuggingFace Hub "suggested models" helpers. + * + * Pure, unit-testable pieces used by + * `GET /api/v1/providers/suggested-models` — that route proxies the public + * HuggingFace Hub models search API (never exposing any HF token + * client-side) and uses these helpers to map a dashboard media "kind" to an + * HF `pipeline_tag`, then sort/limit the raw search results. + */ + +/** Media kinds (mirrors `RegistryMediaKind` in mediaServiceKinds.ts) that currently + * have a mapped HF Hub `pipeline_tag`. Extend as more kinds get suggestions. */ +export const SUGGESTED_MODEL_KIND_PIPELINE_TAGS: Readonly> = { + image: "text-to-image", +}; + +export type SuggestedModelKind = keyof typeof SUGGESTED_MODEL_KIND_PIPELINE_TAGS; + +/** + * Resolve a dashboard media kind (e.g. "image") to the HuggingFace Hub + * `pipeline_tag` used to search https://huggingface.co/api/models. + * Returns null for kinds without a mapped pipeline tag. + */ +export function resolveHfPipelineTag(kind: string): string | null { + return SUGGESTED_MODEL_KIND_PIPELINE_TAGS[kind] ?? null; +} + +/** Minimal shape consumed from the HF Hub `/api/models` search response. */ +export interface HfModelSummary { + id: string; + likes?: number; + downloads?: number; + pipeline_tag?: string; +} + +export type HfSuggestedModelSortBy = "downloads" | "likes"; + +/** + * Pure filter/sort over raw HF Hub model search results: + * - drops entries without a usable string `id` + * - sorts descending by the requested metric (missing/non-numeric treated as 0) + * - caps the result to `limit` entries + * + * No network access — safe to unit test directly with fixture arrays. + */ +export function sortHfSuggestedModels( + models: readonly HfModelSummary[], + sortBy: HfSuggestedModelSortBy = "downloads", + limit = 20 +): HfModelSummary[] { + const valid = (models ?? []).filter( + (m): m is HfModelSummary => !!m && typeof m.id === "string" && m.id.trim().length > 0 + ); + + const sorted = [...valid].sort((a, b) => { + const bVal = Number(b[sortBy]); + const aVal = Number(a[sortBy]); + return (Number.isFinite(bVal) ? bVal : 0) - (Number.isFinite(aVal) ? aVal : 0); + }); + + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 20; + return sorted.slice(0, safeLimit); +} diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 54cc6a3e2c..122247a966 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -32,11 +32,6 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { // or backward-compatible slug changes, not a single provider's display name. // opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier) ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; - -// Manual aliases for external compatibility not covered by PROVIDER_ID_TO_ALIAS. -// OpenCode's Zen provider now uses the "opencode" slug, but OmniRoute registers -// it as "opencode-zen". This alias ensures `opencode/` resolves correctly. -ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; // xiaomi/ is the user-visible prefix for MiMo models; register it so // parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead // of falling through to the identity fallback ("xiaomi"). @@ -147,7 +142,19 @@ interface ProviderConnectionLike { */ export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { if (typeof aliasOrId !== "string") return null; - return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; + // Follow the alias chain transitively so intermediate aliases + // (e.g. "oc" -> "opencode" -> "opencode-zen") resolve to the final target. + // Guarded against infinite loops with both a depth limit and a seen-set. + let current = aliasOrId; + const seen = new Set(); + for (let i = 0; i < 10; i++) { + const next = ALIAS_TO_PROVIDER_ID[current]; + if (!next || next === current) return current; + if (seen.has(next)) return next; + seen.add(next); + current = next; + } + return current; } /** @@ -546,8 +553,15 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } - // Fallback for newly released OpenAI-family model IDs that may not be in the local catalog yet. - if (/^gpt-/i.test(modelId) || /^o1/i.test(modelId) || /^o3/i.test(modelId)) { + // Fallback for newly released OpenAI-family model IDs that may not be in the local + // catalog yet. This must only fire when NO known provider catalogs the model id — + // otherwise it hijacks cataloged open-weight models like "gpt-oss-120b" (served by + // fireworks/cerebras/scaleway/byteplus) into provider "openai", which does not carry + // them (#5852). + if ( + providers.length === 0 && + (/^gpt-/i.test(modelId) || /^o1/i.test(modelId) || /^o3/i.test(modelId)) + ) { return { provider: "openai", model: modelId, diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 216c2748b7..16ef338874 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -75,15 +75,20 @@ const MODEL_FAMILIES: Record = { // Claude Mythos family (Fable 5) — flagship falls to the next-best Opus // tiers before the cheaper Sonnet, matching the Opus family ordering. - "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6"], + "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-5"], // Claude Opus family - "claude-opus-4-8": ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6"], - "claude-opus-4-7": ["claude-opus-4-6", "claude-opus-4-5-20251101", "claude-sonnet-4-6"], - "claude-opus-4-6": ["claude-opus-4-6-thinking", "claude-opus-4-5-20251101", "claude-sonnet-4-6"], + "claude-opus-4-8": ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5"], + "claude-opus-4-7": ["claude-opus-4-6", "claude-opus-4-5-20251101", "claude-sonnet-5"], + "claude-opus-4-6": ["claude-opus-4-6-thinking", "claude-opus-4-5-20251101", "claude-sonnet-5"], "claude-opus-4-6-thinking": ["claude-opus-4-6", "claude-opus-4-5-20251101"], - // Claude Sonnet family + // Claude Sonnet family — Sonnet 5 is the newest tier; degrade to 4.6 → 4.5 → 4. + "claude-sonnet-5": [ + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + ], "claude-sonnet-4-6": ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"], "claude-sonnet-4-5-20250929": ["claude-sonnet-4-6", "claude-sonnet-4-20250514"], diff --git a/open-sse/services/providerCostData.ts b/open-sse/services/providerCostData.ts index b1be9d0203..b9809dea99 100644 --- a/open-sse/services/providerCostData.ts +++ b/open-sse/services/providerCostData.ts @@ -15,6 +15,7 @@ export const KNOWN_MODEL_PRICING: Record = { "claude-opus-4-8": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-4-7": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-sonnet-4-6": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, + "claude-sonnet-5": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, "claude-haiku-4-5": { inputCostPer1M: 0.8, outputCostPer1M: 4.0, isFree: false }, "gemini-2.5-flash": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, "gemini-2.5-pro": { inputCostPer1M: 1.25, outputCostPer1M: 5.0, isFree: false }, diff --git a/open-sse/services/qoderCli.ts b/open-sse/services/qoderCli.ts index b8674483d0..3aa110eee9 100644 --- a/open-sse/services/qoderCli.ts +++ b/open-sse/services/qoderCli.ts @@ -1,12 +1,17 @@ import { spawn } from "child_process"; import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; const DEFAULT_TIMEOUT_MS = 45_000; -const DEFAULT_MAX_TURNS = "1"; +const DEFAULT_MODELS_TIMEOUT_MS = 20_000; const QODER_DEFAULT_MODEL = "qoder-rome-30ba3b"; export const QODER_STATIC_MODELS = [ { id: "qoder-rome-30ba3b", name: "Qoder ROME" }, + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "minimax-m3", name: "MiniMax M3" }, { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, { id: "qwen3-max", name: "Qwen3 Max" }, { id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" }, @@ -72,6 +77,361 @@ export function getQoderCliWorkspace(): string { return home || process.cwd(); } +/** + * Isolated `--config-dir` for OmniRoute-driven qodercli runs. Keeping it separate + * from the operator's own `~/.qoder` avoids polluting an interactive qodercli + * session and lets each PAT authenticate via `QODER_PERSONAL_ACCESS_TOKEN` + * without clobbering a browser login. Override with `QODER_CLI_CONFIG_DIR`. + */ +export function getQoderCliConfigDir(): string { + const explicit = String(process.env.QODER_CLI_CONFIG_DIR || "").trim(); + if (explicit) return explicit; + const dataDir = String(process.env.DATA_DIR || "").trim(); + const base = dataDir || path.join(os.homedir() || os.tmpdir(), ".omniroute"); + return path.join(base, "qoder-cli"); +} + +// Memoized per resolved path so we don't hit synchronous disk I/O +// (fs.mkdirSync blocks the event loop) on every chat/quota request. +const ensuredQoderCliConfigDirs = new Set(); + +/** Ensure the qodercli config dir exists so it is a valid spawn cwd + cache root. */ +function ensureQoderCliConfigDir(): string { + const dir = getQoderCliConfigDir(); + if (ensuredQoderCliConfigDirs.has(dir)) return dir; + try { + fs.mkdirSync(dir, { recursive: true }); + ensuredQoderCliConfigDirs.add(dir); + } catch { + /* best-effort — spawn will surface a real failure */ + } + return dir; +} + +type SpawnQoderCliOptions = { + args: string[]; + token?: string | null; + stdin?: string | null; + signal?: AbortSignal | null; + timeoutMs?: number; + command?: string | null; + cwd?: string | null; +}; + +/** + * Low-level qodercli spawn. The PAT (if any) is passed via the + * `QODER_PERSONAL_ACCESS_TOKEN` env var — the only env var the official CLI + * honors for headless PAT auth — and the prompt is piped through stdin so no + * untrusted value is ever interpolated into a shell command (Hard Rule #13). + */ +function spawnQoderCli(options: SpawnQoderCliOptions): Promise { + const command = String(options.command || "").trim() || getQoderCliCommand(); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const env: NodeJS.ProcessEnv = { ...process.env }; + const token = String(options.token || "").trim(); + if (token) env.QODER_PERSONAL_ACCESS_TOKEN = token; + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + + let child: ReturnType; + try { + child = spawn(command, options.args, { + env, + cwd: options.cwd || undefined, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (err) { + resolve({ + ok: false, + code: null, + stdout: "", + stderr: "", + timedOut: false, + error: (err as Error).message, + }); + return; + } + + const timer = setTimeout(() => { + timedOut = true; + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, timeoutMs); + timer.unref?.(); + + const onAbort = () => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }; + if (options.signal) { + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + const finish = (result: QoderCliRunResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener?.("abort", onAbort); + resolve(result); + }; + + child.on("error", (err: Error) => { + finish({ ok: false, code: null, stdout, stderr, timedOut, error: err.message }); + }); + // If qodercli exits or closes stdin before we finish writing the prompt, the + // write/end below can emit an ASYNC EPIPE/EINVAL on the stream (not caught by + // the surrounding try/catch). Without a listener that becomes an unhandled + // 'error' event that crashes the whole process — attach no-op handlers. + child.stdin?.on("error", () => {}); + child.stdout?.on("error", () => {}); + child.stderr?.on("error", () => {}); + // Decode with a stateful UTF-8 reader so a multi-byte character (e.g. Chinese, + // common in Qoder output) split across two chunks is not corrupted — Buffer + // per-chunk toString() would mangle the boundary bytes. + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("close", (code: number | null) => { + finish({ + ok: code === 0 && !timedOut, + code, + stdout, + stderr, + timedOut, + error: timedOut ? "qodercli timed out" : null, + }); + }); + + try { + if (options.stdin != null) child.stdin?.write(options.stdin); + child.stdin?.end(); + } catch { + /* stdin closed early — the child will surface its own error */ + } + }); +} + +/** + * Run a single non-interactive chat turn through qodercli. Returns the raw + * process result; use {@link parseQoderCliResult} to extract the reply text. + */ +export async function runQoderCli(options: QoderCliRunOptions): Promise { + const level = await resolveQoderCliModel(options.model, options.token, { + command: options.command, + signal: options.signal, + }); + const configDir = ensureQoderCliConfigDir(); + const cwd = String(options.workspace || "").trim() || configDir; + const args = [ + "--print", + "--output-format", + "json", + "--model", + level, + // Disable all built-in tools — OmniRoute only wants a plain LM reply, never + // file-system access or command execution from the proxied CLI. + "--tools", + "", + "--config-dir", + configDir, + ]; + return spawnQoderCli({ + args, + token: options.token, + stdin: options.prompt, + signal: options.signal, + timeoutMs: options.timeoutMs, + command: options.command, + cwd, + }); +} + +/** + * List the models qodercli can reach for the given PAT. Used as a cheap + * connection/credential check (no chat tokens are consumed). + */ +export async function listQoderCliModels( + options: { + token?: string | null; + signal?: AbortSignal | null; + timeoutMs?: number; + command?: string | null; + } = {} +): Promise { + const configDir = ensureQoderCliConfigDir(); + return spawnQoderCli({ + args: ["--list-models", "--config-dir", configDir], + token: options.token, + signal: options.signal, + timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS, + command: options.command, + cwd: configDir, + }); +} + +/** Normalize a model id / display name so "glm-5.2" and "GLM-5.2" compare equal. */ +export function normalizeQoderModelKey(value: unknown): string { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + +/** Extract the display names from a `qodercli --list-models` table. */ +export function parseQoderCliModelNames(stdout: string): string[] { + return String(stdout || "") + .split("\n") + .map((line) => line.replace(/\[[0-9;]*m/g, "").trim()) // strip ANSI colors + .filter( + (line) => + line.length > 0 && + line.toLowerCase() !== "model" && // header row + !/invalid model|not logged in|please run|available model keys/i.test(line) + ); +} + +/** + * Resolve an OmniRoute model id to the exact value to pass to `qodercli -m`. + * Pure (no I/O) so it can be unit-tested against a captured model list. + * + * Preference order: + * 1. A live `--list-models` display name (case-insensitive, punctuation-insensitive) + * — qodercli accepts these directly and they track upstream renames of the + * opaque internal level keys. + * 2. The static family map (level keys) — used when the live list is unavailable + * or has no match. + * 3. "Auto". + */ +export function resolveQoderModelName( + requested: string | null | undefined, + availableNames: string[] +): string { + const normalized = normalizeQoderModelKey(requested); + if (!normalized) return "auto"; + const match = (availableNames || []).find((name) => normalizeQoderModelKey(name) === normalized); + if (match) return match; + return mapQoderModelToLevel(requested) || "auto"; +} + +// Per-token cache of the `--list-models` display names (the catalog is stable and +// per-account); TTL keeps it fresh without a CLI spawn on every request. +const QODER_MODEL_LIST_TTL_MS = 10 * 60 * 1000; +type QoderModelNamesCacheEntry = { names: string[]; expiresAt: number }; +const qoderModelNamesCache = new Map(); +const qoderModelNamesPending = new Map>(); + +async function getCachedQoderCliModelNames( + token?: string | null, + options: { command?: string | null; signal?: AbortSignal | null; now?: number } = {} +): Promise { + const key = String(token || "").trim() || "default"; + const now = options.now ?? Date.now(); + const cached = qoderModelNamesCache.get(key); + if (cached && cached.expiresAt > now) return cached.names; + + let pending = qoderModelNamesPending.get(key); + if (!pending) { + pending = listQoderCliModels({ token, command: options.command, signal: options.signal }) + .then((run) => { + const names = run.ok ? parseQoderCliModelNames(run.stdout) : []; + // Only cache a non-empty success; a failed/empty list should retry next time. + if (names.length > 0) { + qoderModelNamesCache.set(key, { names, expiresAt: now + QODER_MODEL_LIST_TTL_MS }); + } + return names; + }) + .catch(() => [] as string[]) + .finally(() => qoderModelNamesPending.delete(key)); + qoderModelNamesPending.set(key, pending); + } + return pending; +} + +/** Async resolver: matches the request against the live (cached) `--list-models`. */ +export async function resolveQoderCliModel( + requested: string | null | undefined, + token?: string | null, + options: { command?: string | null; signal?: AbortSignal | null } = {} +): Promise { + let names: string[] = []; + try { + names = await getCachedQoderCliModelNames(token, options); + } catch { + names = []; + } + return resolveQoderModelName(requested, names); +} + +/** Test-only: drop the cached `--list-models` names so unit tests don't leak state. */ +export function __clearQoderModelNamesCache(): void { + qoderModelNamesCache.clear(); + qoderModelNamesPending.clear(); +} + +/** + * Parse the `--output-format json` envelope qodercli prints in print mode. The + * CLI may emit banner/log lines before the JSON, so we fall back to scanning for + * the last JSON object line. Returns the assistant text plus an error flag. + */ +export function parseQoderCliResult(stdout: string): { + text: string; + isError: boolean; + errorMessage: string; +} { + const trimmed = String(stdout || "").trim(); + if (!trimmed) { + return { text: "", isError: true, errorMessage: "qodercli produced no output" }; + } + + let parsed: JsonRecord | null = null; + try { + const whole = JSON.parse(trimmed); + if (whole && typeof whole === "object") parsed = whole as JsonRecord; + } catch { + for (const line of trimmed.split("\n").reverse()) { + const candidate = line.trim(); + if (!candidate.startsWith("{")) continue; + try { + const obj = JSON.parse(candidate); + if (obj && typeof obj === "object") { + parsed = obj as JsonRecord; + break; + } + } catch { + /* keep scanning earlier lines */ + } + } + } + + if (!parsed) { + return { text: "", isError: true, errorMessage: trimmed.slice(0, 300) }; + } + + const result = getString(parsed.result); + const isError = + parsed.is_error === true || getString(parsed.subtype).trim().toLowerCase() === "error"; + return { + text: result, + isError, + errorMessage: isError ? result || "qodercli returned an error" : "", + }; +} + export function normalizeQoderPatProviderData(providerSpecificData: JsonRecord = {}): JsonRecord { return { ...providerSpecificData, @@ -92,12 +452,33 @@ export function getStaticQoderModels() { return QODER_STATIC_MODELS.map((model) => ({ ...model })); } +/** qodercli's `-m` accepts these level keys (see `qodercli --list-models`). */ +const QODER_LEVEL_KEYS = new Set([ + "auto", + "ultimate", + "performance", + "efficient", + "lite", + "q35model_preview", + "qmodel_latest", + "qmodel", + "gm51model", + "kmodel", + "dmodel", + "dfmodel", + "mmodel", +]); + export function mapQoderModelToLevel(model: string | null | undefined): string | null { const normalized = String(model || "") .trim() .toLowerCase(); if (!normalized) return null; + // A caller may pass a qodercli level key directly (e.g. "gm51model") — honor it. + if (QODER_LEVEL_KEYS.has(normalized)) return normalized; if (normalized.includes("deepseek-r1")) return "ultimate"; + if (normalized.includes("glm")) return "gm51model"; // GLM-5.2 (`qoder/glm-5.2`) + if (normalized.includes("minimax")) return "mmodel"; if (normalized.includes("qwen3-max")) return "performance"; if (normalized.includes("kimi-k2")) return "kmodel"; if (normalized.includes("qwen3-coder")) return "qmodel"; @@ -310,7 +691,13 @@ export function parseQoderCliFailure(stderrText: string, stdoutText = ""): Qoder if ( normalized.includes("invalid api key") || normalized.includes("invalid token") || + normalized.includes("invalid personal token") || normalized.includes("personal access token") || + normalized.includes("personal token format") || + normalized.includes("exchangejobtoken failed") || + normalized.includes("not logged in") || + normalized.includes("please run /login") || + normalized.includes("login required") || (normalized.includes("unauthorized") && normalized.includes("qoder")) ) { return { status: 401, message: combined, code: "upstream_auth_error" }; @@ -541,126 +928,61 @@ export async function validateQoderCliPat({ }; } - const modelId = - getString(providerSpecificData.validationModelId).trim() || - getString(providerSpecificData.modelId).trim() || - QODER_DEFAULT_MODEL; + // Reference providerSpecificData so callers can still pass validation hints + // (model id, etc.) without a signature change; the CLI resolves models itself. + void providerSpecificData; - const bodyStr = JSON.stringify({ - model: modelId || "coder-model", - messages: [{ role: "user", content: "hi" }], - stream: false, - }); + // Validate by asking the local qodercli to list the models reachable for this + // PAT. The official CLI signs the (WASM-based) Cosy request internally, which + // the pure-HTTP path can no longer replicate — a raw Cosy call now returns a + // generic 500 for every token, so it cannot distinguish valid from invalid. + // `--list-models` authenticates without consuming any chat tokens. + const run = await listQoderCliModels({ token: resolvedToken }); + const combined = `${run.stdout}\n${run.stderr}`.trim(); + const normalized = combined.toLowerCase(); - // Step 1: Connectivity check — verify Qoder API is reachable - try { - const pingRes = await fetch("https://api1.qoder.sh/algo/api/v1/ping", { - method: "GET", - // @ts-ignore - signal: AbortSignal.timeout(10000), - }); - if (!pingRes.ok) { - return { - valid: false, - error: `Qoder API unreachable (ping returned ${pingRes.status}). Check your network/proxy configuration.`, - unsupported: false, - }; - } - } catch (pingErr: any) { + if (run.error && /enoent|not found|no such file|spawn/i.test(run.error)) { return { valid: false, error: - `Cannot reach Qoder API (${pingErr.message}). ` + - "If behind a proxy, configure HTTPS_PROXY. For Docker, ensure the container has internet access.", + `Qoder CLI (qodercli) was not found on the OmniRoute host (${run.error}). ` + + "Install it from https://qoder.com or point CLI_QODER_BIN at the binary. " + + "PAT auth is driven through the local qodercli binary.", unsupported: false, }; } - // Step 2: Auth validation — exchange the PAT for a job token (#4683), then send a - // minimal request with the `jt-*` (Cosy rejects a raw `pt-*` with a generic 500). - const cosyToken = await resolveQoderJobToken(resolvedToken); - const headers = buildCosyHeadersForValidation(bodyStr, cosyToken); - const endpoint = - "https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common"; - - try { - const res = await fetch(endpoint, { - method: "POST", - headers, - body: bodyStr, - // @ts-ignore - signal: AbortSignal.timeout(30000), - }); - - if (res.ok || res.status === 200) { - return { valid: true, error: null, unsupported: false }; - } - - // Parse error body for better diagnostics - let errorDetail = ""; - try { - const errBody = await res.text(); - errorDetail = errBody.slice(0, 300); - } catch {} - - if (res.status === 401 || res.status === 403) { - return { - valid: false, - error: - `Authentication failed (HTTP ${res.status}). ` + - "Make sure you're using a valid Personal Access Token from https://qoder.com/account/integrations. " + - "Note: tokens from ~/.qoder/.auth/user are encrypted and cannot be used directly." + - (errorDetail ? ` Server: ${errorDetail}` : ""), - unsupported: false, - }; - } - - // 4xx other than auth — token was accepted but request had issues (model, format, etc.) - if (res.status >= 400 && res.status < 500) { - return { valid: true, error: null, unsupported: false }; - } - - // Treat 5xx as a valid bypass to prevent false negatives from legacy Qoder APIs (#1391). - // A Cosy `{"success":false}` 500 is ambiguous: it can be a genuine auth rejection OR a - // transient/generic upstream "Internal Server Error". Only mark the PAT invalid when the - // body carries an EXPLICIT auth signal — a generic 500 is a server fault, not an auth - // verdict, so a working PAT must not be reported as expired (#3247, narrowing #2860). - if (res.status >= 500) { - const isCosyResponse = /"success"\s*:\s*false/.test(errorDetail); - const hasAuthSignal = - /(unauthorized|forbidden|expired|revoked|not\s*authorized|permission\s*denied|access\s*denied|invalid\s*(?:token|credential|api[\s_-]*key)|token\s*(?:invalid|expired|revoked))/i.test( - errorDetail - ); - - if (isCosyResponse && hasAuthSignal) { - return { - valid: false, - error: - `Authentication failed (HTTP ${res.status}). The Qoder Cosy server rejected the token ` + - "as invalid, expired, or not authorized. " + - "Please check your token at https://qoder.com/account/integrations." + - (errorDetail ? ` Server response: ${errorDetail}` : ""), - unsupported: false, - }; - } - - return { - valid: true, - error: `Validation endpoint returned HTTP ${res.status}${errorDetail ? `: ${errorDetail}` : ""}, treating PAT as valid`, - unsupported: false, - }; - } - + if (run.timedOut) { return { valid: false, - error: `Qoder API returned HTTP ${res.status}${errorDetail ? `: ${errorDetail}` : ""}`, - unsupported: false, - }; - } catch (e: any) { - return { - valid: false, - error: `Qoder validation request failed: ${e.message}`, + error: + "qodercli timed out while validating the token. Check network/proxy access from the OmniRoute host.", unsupported: false, }; } + + if ( + /not logged in|please run \/login|login required|unauthorized|forbidden|exchangejobtoken failed|personal token format|invalid[\s\w]{0,40}?(?:token|credential|api[\s_-]*key)/i.test( + normalized + ) + ) { + return { + valid: false, + error: + "Qoder rejected this Personal Access Token (not authorized). " + + "Check your token at https://qoder.com/account/integrations.", + unsupported: false, + }; + } + + // A successful `--list-models` prints the catalog (a table headed by "MODEL"). + if (run.ok && normalized.includes("model")) { + return { valid: true, error: null, unsupported: false }; + } + + return { + valid: false, + error: `qodercli validation failed: ${(combined || run.error || "unknown error").slice(0, 300)}`, + unsupported: false, + }; } diff --git a/open-sse/services/retryAfterJson.ts b/open-sse/services/retryAfterJson.ts new file mode 100644 index 0000000000..1426fc3447 --- /dev/null +++ b/open-sse/services/retryAfterJson.ts @@ -0,0 +1,44 @@ +type JsonRecord = Record; + +function objectRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function positiveCappedMs(value: unknown, maxMs: number): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.min(value, maxMs) + : null; +} + +function futureTimestampMs(value: unknown, maxMs: number): number | null { + if (typeof value !== "string") return null; + const parsedTs = Date.parse(value); + if (!Number.isFinite(parsedTs)) return null; + const waitMs = parsedTs - Date.now(); + return waitMs > 0 ? Math.min(waitMs, maxMs) : null; +} + +/** + * Parse Retry-After hints from a 429 JSON response body. Providers use both + * top-level and nested `error` fields for ISO timestamps and millisecond values. + */ +export function parseRetryHintFromJsonBody(body: string, maxMs: number): number | null { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + + const root = objectRecord(parsed); + if (!Object.keys(root).length) return null; + const errorObj = objectRecord(root.error); + + const isoHint = futureTimestampMs(errorObj.retryAfter ?? root.retryAfter, maxMs); + if (isoHint !== null) return isoHint; + + return positiveCappedMs( + errorObj.retry_after_ms ?? root.retry_after_ms ?? errorObj.retryAfterMs ?? root.retryAfterMs, + maxMs + ); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index a65d21daf8..39f8296082 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -14,6 +14,7 @@ import { extractCodeAssistSubscriptionTier, } from "./codeAssistSubscription.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveQoderJobToken } from "./qoderCli.ts"; import { toRecord, toNumber, @@ -311,6 +312,43 @@ async function getXiaomiMimoUsage(connectionId: string) { } } +/** + * xAI (Grok) — SELF-TRACKED cumulative usage. + * + * xAI has no public per-account quota API (the billing console at console.x.ai + * requires a session cookie, not an API key), so — exactly like the Xiaomi + * MiMo self-track pattern above — OmniRoute sums the tokens it itself routed + * to this connection (from `usage_history`) instead of calling an upstream + * endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the + * aggregate is reported as `unlimited: true` with `remaining: 100` — this + * renders the dashboard's green "100%" badge instead of a meaningless + * progress bar against a `total: 0`. + */ +async function getXaiUsage(connectionId: string) { + if (!connectionId) { + return { message: "xAI: connection id unavailable for self-tracked usage." }; + } + try { + const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); + const used = getMonthlyProviderTokensForConnection("xai", connectionId); + return { + plan: "xAI / Grok (OmniRoute-tracked)", + quotas: { + monthly: { + used, + total: 0, + remaining: 100, + remainingPercentage: 100, + resetAt: null, + unlimited: true, + } as UsageQuota, + }, + }; + } catch (error) { + return { message: `xAI self-tracked usage error: ${(error as Error).message}` }; + } +} + /** * OpenCode Go / OpenCode / OpenCode Zen Usage * Delegates to the dedicated opencodeQuotaFetcher and shapes the result into @@ -496,6 +534,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "opencode", "opencode-zen", "xiaomi-mimo", + "xai", "vertex", "vertex-partner", "codebuddy-cn", @@ -546,7 +585,9 @@ export async function getUsageForProvider( case "qwen": return await getQwenUsage(accessToken, providerSpecificData); case "qoder": - return await getQoderUsage(accessToken); + // Qoder PATs live in `apiKey` (decrypted) or `providerSpecificData.qoderPat`, + // never in `accessToken`. + return await getQoderUsage(apiKey, providerSpecificData); case "glm": case "glm-cn": case "zai": @@ -575,6 +616,8 @@ export async function getUsageForProvider( return await getOpencodeUsage(id || "", apiKey || ""); case "xiaomi-mimo": return await getXiaomiMimoUsage(id || ""); + case "xai": + return await getXaiUsage(id || ""); case "codebuddy-cn": return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData); default: @@ -858,19 +901,132 @@ async function getQwenUsage(accessToken?: string, providerSpecificData?: JsonRec /** * Qoder Usage + * + * Qoder exposes account plan + quota at `openapi.qoder.sh/api/v3/user/status`, + * the same endpoint the official qodercli reads for its usage badge. The status + * call needs a short-lived `jt-*` job token, so we exchange the PAT the same way + * the chat/validation paths do (see qoderCli.ts::resolveQoderJobToken). */ -async function getQoderUsage(accessToken?: string) { - void accessToken; - try { - // Qoder may have usage endpoint - return { message: "Qoder connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch Qoder usage." }; +const QODER_USER_STATUS_URL = "https://openapi.qoder.sh/api/v3/user/status"; + +/** Human-readable plan label from Qoder's `PLAN_TIER_*` enum / `userTag`. */ +function prettifyQoderPlan(planRaw: string, userTag: string): string { + const tag = String(userTag || "").trim(); + if (tag) return tag; + const stripped = String(planRaw || "") + .trim() + .replace(/^PLAN_TIER_/i, ""); + return stripped ? toTitleCase(stripped) : "Qoder"; +} + +/** + * Map a Qoder `/user/status` payload into the shared `{ plan, quotas }` shape. + * Pure (no I/O) so it can be unit-tested against captured payloads. + */ +export function parseQoderUserStatusUsage(status: JsonRecord): { + plan: string; + quotas: Record; +} { + const userType = String(status.userType || "") + .trim() + .toLowerCase(); + const planLabel = prettifyQoderPlan(String(status.plan || ""), String(status.userTag || "")); + const isExceeded = status.isQuotaExceeded === true; + const quotaNum = toNumber(status.quota, 0); + const resetAt = parseResetTime(status.nextResetAt); + // Team/enterprise seats draw from a pooled org quota rather than a per-user + // counter, so `quota: 0` there means "pooled", not "exhausted". + const isPooled = userType === "teams" || userType === "enterprise"; + + const quotas: Record = {}; + if (isExceeded) { + // Genuinely out of quota — remainingPercentage 0 lets routing skip it until reset. + quotas["Quota"] = { + used: quotaNum, + total: quotaNum, + remaining: 0, + remainingPercentage: 0, + resetAt, + unlimited: false, + displayName: "Quota exceeded", + }; + } else if (isPooled || quotaNum <= 0) { + // Pooled/unlimited seat — MUST report 100% remaining. The quota→routing + // conversion (src/domain/quotaCache.ts) ignores `unlimited` and would treat a + // `total: 0` window as 0% (i.e. exhausted), wrongly 429-ing every request. + quotas["Plan"] = { + used: 0, + total: 0, + remaining: 0, + remainingPercentage: 100, + resetAt, + unlimited: true, + displayName: `${planLabel} plan · pooled quota`, + }; + } else { + quotas["Requests"] = { + used: 0, + total: quotaNum, + remaining: quotaNum, + remainingPercentage: 100, + resetAt, + unlimited: false, + displayName: `${quotaNum} requests left`, + }; } + + return { plan: planLabel, quotas }; +} + +async function getQoderUsage(apiKey?: string, providerSpecificData?: JsonRecord) { + const token = (apiKey || "").trim() || String(providerSpecificData?.qoderPat || "").trim(); + if (!token) { + return { message: "Qoder connected. Add a Personal Access Token to view quota." }; + } + + let jobToken: string; + try { + jobToken = await resolveQoderJobToken(token); + } catch { + return { message: "Qoder connected. Unable to resolve a usage token." }; + } + + let response: Response; + try { + response = await fetch(QODER_USER_STATUS_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json" }, + // @ts-ignore — AbortSignal.timeout is available on the Node runtime + signal: AbortSignal.timeout(15000), + }); + } catch (error) { + return { + message: `Qoder connected. Unable to fetch usage: ${sanitizeErrorMessage((error as Error).message)}`, + }; + } + + if (response.status === 401 || response.status === 403) { + return { + message: "Qoder connected. The token was rejected by the usage API — re-test the connection.", + }; + } + if (!response.ok) { + return { message: `Qoder connected. Usage API returned HTTP ${response.status}.` }; + } + + let status: JsonRecord; + try { + status = toRecord(await response.json()); + } catch { + return { message: "Qoder connected. Unable to parse the usage response." }; + } + + return parseQoderUserStatusUsage(status); } export const __testing = { parseResetTime, + parseQoderUserStatusUsage, formatGitHubQuotaSnapshot, inferGitHubPlanName, getAntigravityPlanLabel, @@ -890,6 +1046,7 @@ export const __testing = { getMiniMaxRemainingPercent, getMiniMaxUsage, getXiaomiMimoUsage, + getXaiUsage, getVertexUsage, getMiniMaxAuthErrorMessage, getMiniMaxErrorSummary, diff --git a/open-sse/services/usage/codex.ts b/open-sse/services/usage/codex.ts index b1d0261f03..564cbdad9d 100644 --- a/open-sse/services/usage/codex.ts +++ b/open-sse/services/usage/codex.ts @@ -57,12 +57,17 @@ export async function getCodexUsage( const data = await response.json(); - const { rateLimit, quotas } = buildCodexUsageQuotas(data); + const { rateLimit, quotas, bankedResetCredits, rateLimitReachedType } = + buildCodexUsageQuotas(data); return { plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"), limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")), quotas, + // Banked reset credits (display-only, eligibility-gated — issue #5199). + // Absent for most accounts; never throws when the upstream omits it. + ...(bankedResetCredits !== undefined ? { bankedResetCredits } : {}), + ...(rateLimitReachedType !== undefined ? { rateLimitReachedType } : {}), }; } catch (error) { return { message: `Failed to fetch Codex usage: ${(error as Error).message}` }; diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 515765a2f2..5fd500b2da 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -12,6 +12,10 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([ "maxLength", "exclusiveMinimum", "exclusiveMaximum", + // `multipleOf` is not part of the Gemini/antigravity OpenAPI 3.0 schema subset; + // leaving it in function_declarations triggers a hard upstream 400 + // ("Unknown name \"multipleOf\""). `minimum`/`maximum` ARE accepted and kept. + "multipleOf", // NOTE: `pattern` is intentionally NOT in this set. Antigravity (Gemini-derived // surface) accepts `pattern` on string constraints, and glob/grep/file-search // tools depend on it to express their argument regex. Removing it produced diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index 24d25d4b11..032d509127 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -1,6 +1,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; +import { fixToolPairs } from "../../services/contextManager.ts"; type JsonRecord = Record; @@ -96,6 +97,19 @@ export function antigravityToOpenAIRequest(model, body, stream) { } } + // Guard against orphan tool_result/tool_use pairs (#6026). Antigravity IDE can ship a + // truncated history whose first turn is a `functionResponse` with no preceding + // `functionCall`. Left untouched, that becomes an orphan `role:"tool"` message here and, + // after the openai→claude step, an orphan `tool_result` block — which Anthropic (Vertex + // `claude-opus-4.6`) rejects with `unexpected tool_use_id found in tool_result blocks`. + // `fixToolPairs` strips only genuine orphans and is idempotent on well-formed histories, + // so paired functionCall/functionResponse turns pass through unchanged. This mirrors the + // executor-side guard in `executors/base.ts` / `services/claudeCodeCompatible.ts`; the + // Antigravity MITM path did not run it (no `fixToolPairs` under `src/mitm/`). We do NOT + // run `fixToolAdjacency` here because this stage still emits OpenAI-format messages and + // Claude's adjacency rule is enforced downstream per provider. + result.messages = fixToolPairs(result.messages) as JsonRecord[]; + return result; } @@ -229,14 +243,17 @@ function convertContent(content) { continue; } - // Text with thoughtSignature = regular text after thinking + // Text with thoughtSignature = regular text after thinking. + // Skip empty text — Anthropic rejects empty content blocks with a 400. if (part.thoughtSignature && part.text !== undefined) { - textParts.push({ type: "text", text: part.text }); + if (part.text) { + textParts.push({ type: "text", text: part.text }); + } continue; } - // Regular text - if (part.text !== undefined) { + // Regular text — skip empty strings (Anthropic rejects empty content blocks). + if (part.text !== undefined && part.text !== "") { textParts.push({ type: "text", text: part.text }); } @@ -274,8 +291,26 @@ function convertContent(content) { } } - // Content with only functionResponses → return array of tool messages + // Function responses may be co-located with function calls / text / reasoning in + // the same content. Emit the tool messages AND the accompanying assistant message so + // nothing is dropped (previously only the tool messages survived). if (toolResults.length > 0) { + if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) { + const assistantMsg: JsonRecord = { role: "assistant" }; + if (textParts.length > 0) { + assistantMsg.content = + textParts.length === 1 && textParts[0].type === "text" + ? textParts[0].text + : textParts; + } + if (reasoningContent) { + assistantMsg.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls; + } + return [...toolResults, assistantMsg]; + } return toolResults; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index a79259bd67..23f32f53d4 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -6,73 +6,28 @@ */ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; import { FORMATS } from "../formats.ts"; -import { generateToolCallId } from "../helpers/toolCallHelper.ts"; import { register } from "../registry.ts"; import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts"; -type JsonRecord = Record; -const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; -const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; +import { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; +import { + JsonRecord, + RESPONSES_STORE_MARKER, + COPILOT_REASONING_SUMMARY_MARKER, + WEB_SEARCH_TOOL_TYPES, + TOOL_SEARCH_TOOL_TYPES, + IMAGE_GENERATION_TOOL_TYPES, + toRecord, + toArray, + toString, + normalizeVerbosity, + normalizeResponsesReasoningEffort, + shouldRequestClaudeSummarizedThinking, + unsupportedFeature, +} from "./openai-responses/helpers.ts"; -// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. -const WEB_SEARCH_TOOL_TYPES = /^web_search/; -// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions -// equivalent and must be silently dropped (not rejected with 400). -const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; -// image_generation is a Responses API hosted tool that Codex Desktop injects into every request -// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950). -const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/; - -// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the -// Responses API. Only these three levels are valid upstream; anything else is dropped. -const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]); -function normalizeVerbosity(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const level = value.toLowerCase(); - return VERBOSITY_LEVELS.has(level) ? level : undefined; -} - -function toRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -// The Responses API rejects call_id values longer than 64 characters (9router#396). -// Clamp deterministically so a function_call and its matching function_call_output keep -// the same id and stay paired through the orphaned-output filter below. -const MAX_CALL_ID_LEN = 64; -function clampCallId(id: string): string { - return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id; -} - -function toArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function toString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function imageUrlToText(value: unknown): string { - if (typeof value === "string") return value; - const record = toRecord(value); - return toString(record.url); -} - -function normalizeResponsesReasoningEffort(value: unknown): string { - const effort = toString(value).toLowerCase(); - return effort === "max" ? "xhigh" : effort; -} - -function shouldRequestClaudeSummarizedThinking(value: unknown): boolean { - const summary = toString(value).toLowerCase(); - return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled"; -} - -function unsupportedFeature(message: string): Error & { statusCode: number; errorType: string } { - const error = new Error(message) as Error & { statusCode: number; errorType: string }; - error.statusCode = 400; - error.errorType = "unsupported_feature"; - return error; -} +// chat -> Responses direction extracted to a pure leaf; re-exported for external +// importers (tests). Host imports it back for registration below. +export { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; /** * Convert OpenAI Responses API request to OpenAI Chat Completions format @@ -569,326 +524,6 @@ export function openaiResponsesToOpenAIRequest( return result; } -/** - * Convert OpenAI Chat Completions to OpenAI Responses API format - */ -export function openaiToOpenAIResponsesRequest( - model: unknown, - body: unknown, - stream: unknown, - credentials: unknown -): unknown { - void stream; - - const root = toRecord(body); - const credentialRecord = toRecord(credentials); - const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); - const result: JsonRecord = { - model, - input: [], - stream: true, - }; - if (!storeEnabled) { - result.store = false; - } - - const input = result.input as JsonRecord[]; - - // Extract first system message as instructions - let hasSystemMessage = false; - const messages = toArray(root.messages); - - for (const messageValue of messages) { - const msg = toRecord(messageValue); - const role = toString(msg.role); - - if (role === "system" || role === "developer") { - if (!hasSystemMessage) { - result.instructions = typeof msg.content === "string" ? msg.content : ""; - hasSystemMessage = true; - } - continue; - } - - // Convert user messages - if (role === "user") { - const content = - typeof msg.content === "string" - ? [{ type: "input_text", text: msg.content }] - : Array.isArray(msg.content) - ? msg.content.map((contentValue) => { - const contentItem = toRecord(contentValue); - if (contentItem.type === "text") { - return { type: "input_text", text: toString(contentItem.text) }; - } - if (contentItem.type === "image_url") { - const imgUrl = contentItem.image_url as - | string - | { url?: string; detail?: string }; - const imgResult: JsonRecord = { - type: "input_image", - image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "", - }; - if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) { - imgResult.detail = imgUrl.detail; - } - return imgResult; - } - if ( - contentItem.type === "image" && - typeof contentItem.image === "string" && - /^data:([^;]+);base64,(.+)$/.test(contentItem.image) - ) { - // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) - const imgResult: JsonRecord = { - type: "input_image", - image_url: contentItem.image, - detail: contentItem.detail !== undefined ? contentItem.detail : "auto", - }; - return imgResult; - } - if (contentItem.type === "file" || contentItem.type === "document") { - // Accept both the OpenAI `file` shape and the Gemini-style `document` shape, - // and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses - // regardless of which content-part name the client used (#2515). - const file = toRecord( - contentItem.type === "document" ? contentItem.document : contentItem.file - ); - const fileResult: JsonRecord = { type: "input_file" }; - if (file.file_data !== undefined) fileResult.file_data = file.file_data; - else if (file.data !== undefined) fileResult.file_data = file.data; - if (file.file_id !== undefined) fileResult.file_id = file.file_id; - if (file.file_url !== undefined) fileResult.file_url = file.file_url; - else if (file.url !== undefined) fileResult.file_url = file.url; - if (file.filename !== undefined) fileResult.filename = file.filename; - else if (file.name !== undefined) fileResult.filename = file.name; - return fileResult; - } - return contentValue; - }) - : [{ type: "input_text", text: "" }]; - - input.push({ - type: "message", - role: "user", - content, - }); - } - - // Convert assistant messages - if (role === "assistant") { - // Skip reasoning_content — OpenAI Responses API requires server-generated - // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) - // causes 400 errors from Responses-compatible upstreams. (#224) - - // Skip thinking blocks in array content — same rs_* ID constraint applies - - // Build assistant output content - const outputContent: unknown[] = []; - if (typeof msg.content === "string" && msg.content) { - outputContent.push({ type: "output_text", text: msg.content }); - } else if (Array.isArray(msg.content)) { - for (const contentValue of msg.content) { - const contentItem = toRecord(contentValue); - if (contentItem.type === "text") { - outputContent.push({ type: "output_text", text: toString(contentItem.text) }); - } else if (contentItem.type === "image_url") { - const url = imageUrlToText(contentItem.image_url); - outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }); - } else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") { - // Reasoning already moved above - continue; - } else { - outputContent.push(contentValue); - } - } - } - - // Only add assistant message if content exists - if (outputContent.length > 0) { - input.push({ - type: "message", - role: "assistant", - content: outputContent, - }); - } - - // Convert tool_calls to function_call items - if (Array.isArray(msg.tool_calls)) { - for (const toolCallValue of msg.tool_calls) { - const toolCall = toRecord(toolCallValue); - const fn = toRecord(toolCall.function); - // Skip tool calls with empty names to avoid infinite placeholder_tool loops - const fnName = toString(fn.name).trim(); - if (!fnName) { - continue; - } - input.push({ - type: "function_call", - call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), - name: fnName, - arguments: toString(fn.arguments, "{}"), - }); - } - } - - // Handle deprecated function_call field (pre-tool_calls API) - if (msg.function_call && !msg.tool_calls) { - const fc = toRecord(msg.function_call); - const fnName = toString(fc.name).trim(); - if (fnName) { - input.push({ - type: "function_call", - call_id: clampCallId(`call_${fnName}`), - name: fnName, - arguments: toString(fc.arguments, "{}"), - }); - } - } - } - - // Convert tool results - if (role === "tool") { - input.push({ - type: "function_call_output", - call_id: clampCallId(toString(msg.tool_call_id)), - output: - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map((c) => { - const part = toRecord(c); - if (part.type === "text") - return { type: "input_text", text: toString(part.text) }; - return c; - }) - : String(msg.content ?? ""), - }); - } - - // Handle deprecated function role messages - if (role === "function") { - input.push({ - type: "function_call_output", - call_id: clampCallId(`call_${toString(msg.name)}`), - output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), - }); - } - } - - // Filter orphaned function_call_output items (no matching function_call) - // This happens when Claude Code compaction removes messages but leaves tool results - const knownCallIds = new Set( - input - .filter( - (item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id - ) - .map((item: { type?: string; call_id?: string }) => item.call_id) - ); - result.input = input.filter((item: { type?: string; call_id?: string }) => { - if (item.type === "function_call_output" && item.call_id) { - return knownCallIds.has(item.call_id); - } - return true; - }); - - // If no system message, keep empty instructions - if (!hasSystemMessage) { - result.instructions = ""; - } - - // Convert tools format - if (Array.isArray(root.tools)) { - result.tools = root.tools.map((toolValue) => { - const tool = toRecord(toolValue); - if (tool.type === "function") { - const fn = toRecord(tool.function); - const name = toString(fn.name); - return { - type: "function", - name, - description: toString(fn.description), - parameters: fn.parameters, - strict: fn.strict, - }; - } - return toolValue; - }); - } - - // Translate tool_choice: Chat {type,function:{name}} → Responses {type,name} - if (root.tool_choice !== undefined) { - if (typeof root.tool_choice === "string") { - result.tool_choice = root.tool_choice; - } else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) { - const tc = toRecord(root.tool_choice); - if (tc.type === "function" && tc.function) { - const fn = toRecord(tc.function); - result.tool_choice = { type: "function", name: fn.name }; - } else { - result.tool_choice = root.tool_choice; - } - } else { - result.tool_choice = root.tool_choice; - } - } - - // Pass through relevant fields - if (root.previous_response_id !== undefined) { - result.previous_response_id = root.previous_response_id; - } - if (root.prompt_cache_key !== undefined) { - result.prompt_cache_key = root.prompt_cache_key; - } - if (root.session_id !== undefined) { - result.session_id = root.session_id; - } - if (root.conversation_id !== undefined) { - result.conversation_id = root.conversation_id; - } - if (root.service_tier !== undefined) result.service_tier = root.service_tier; - if (root.temperature !== undefined) result.temperature = root.temperature; - // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. - // The Responses API does not accept max_tokens or max_completion_tokens; it requires - // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. - if (root.max_completion_tokens !== undefined) { - result.max_output_tokens = root.max_completion_tokens; - } else if (root.max_tokens !== undefined) { - result.max_output_tokens = root.max_tokens; - } - if (root.top_p !== undefined) result.top_p = root.top_p; - // GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`. - const chatVerbosity = normalizeVerbosity(root.verbosity); - if (chatVerbosity) { - result.text = { ...toRecord(result.text), verbosity: chatVerbosity }; - } - if (root.reasoning !== undefined) { - result.reasoning = root.reasoning; - } else if (root.reasoning_effort !== undefined) { - const effort = normalizeResponsesReasoningEffort(root.reasoning_effort); - if (effort) { - result.reasoning = { effort }; - } - } - - // Propagate Responses-API-only fields when a chat client sent them. - // Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on - // the way upstream and Codex returns an empty reasoning summary, so clients - // (OpenCode, Cursor, etc.) see no thinking stream. - if (Array.isArray(root.include) && root.include.length > 0) { - result.include = root.include; - } - if (storeEnabled) { - if (root[RESPONSES_STORE_MARKER] !== undefined) { - result.store = root[RESPONSES_STORE_MARKER]; - } else if (root.store !== undefined) { - result.store = root.store; - } - } - - return result; -} - // Register both directions register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null); register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null); diff --git a/open-sse/translator/request/openai-responses/helpers.ts b/open-sse/translator/request/openai-responses/helpers.ts new file mode 100644 index 0000000000..f50f5132ce --- /dev/null +++ b/open-sse/translator/request/openai-responses/helpers.ts @@ -0,0 +1,69 @@ +// Pure shared primitives for the OpenAI Responses <-> Chat Completions request +// translators. Extracted verbatim from openai-responses.ts (no host imports). + +export type JsonRecord = Record; +export const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; +export const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; + +// Forward-compatible regex: matches web_search, web_search_20250305, and future versioned names. +export const WEB_SEARCH_TOOL_TYPES = /^web_search/; +// tool_search is a Responses API built-in sent by newer Codex clients; it has no Chat Completions +// equivalent and must be silently dropped (not rejected with 400). +export const TOOL_SEARCH_TOOL_TYPES = /^tool_search/; +// image_generation is a Responses API hosted tool that Codex Desktop injects into every request +// (even text-only ones); it has no Chat Completions equivalent and must be silently dropped (#2950). +export const IMAGE_GENERATION_TOOL_TYPES = /^image_generation/; + +// GPT-5 output verbosity: `verbosity` on Chat Completions, `text.verbosity` on the +// Responses API. Only these three levels are valid upstream; anything else is dropped. +export const VERBOSITY_LEVELS = new Set(["low", "medium", "high"]); +export function normalizeVerbosity(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const level = value.toLowerCase(); + return VERBOSITY_LEVELS.has(level) ? level : undefined; +} + +export function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +// The Responses API rejects call_id values longer than 64 characters (9router#396). +// Clamp deterministically so a function_call and its matching function_call_output keep +// the same id and stay paired through the orphaned-output filter below. +export const MAX_CALL_ID_LEN = 64; +export function clampCallId(id: string): string { + return id.length > MAX_CALL_ID_LEN ? id.slice(0, MAX_CALL_ID_LEN) : id; +} + +export function toArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function toString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function imageUrlToText(value: unknown): string { + if (typeof value === "string") return value; + const record = toRecord(value); + return toString(record.url); +} + +export function normalizeResponsesReasoningEffort(value: unknown): string { + const effort = toString(value).toLowerCase(); + return effort === "max" ? "xhigh" : effort; +} + +export function shouldRequestClaudeSummarizedThinking(value: unknown): boolean { + const summary = toString(value).toLowerCase(); + return !!summary && summary !== "off" && summary !== "none" && summary !== "disabled"; +} + +export function unsupportedFeature( + message: string +): Error & { statusCode: number; errorType: string } { + const error = new Error(message) as Error & { statusCode: number; errorType: string }; + error.statusCode = 400; + error.errorType = "unsupported_feature"; + return error; +} diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts new file mode 100644 index 0000000000..d52608496f --- /dev/null +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -0,0 +1,357 @@ +/** + * Translator: OpenAI Chat Completions -> OpenAI Responses API + * + * Extracted verbatim from openai-responses.ts. Registration stays in the host. + */ +import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults"; +import { generateToolCallId } from "../../helpers/toolCallHelper.ts"; +import { + JsonRecord, + RESPONSES_STORE_MARKER, + toRecord, + toArray, + toString, + clampCallId, + imageUrlToText, + normalizeVerbosity, + normalizeResponsesReasoningEffort, +} from "./helpers.ts"; + +// Chat Completions `response_format: { type: "json_schema" }` → Responses API `text.format`. +// Merges into any existing `result.text` (e.g. verbosity) so structured-output schemas from +// Chat clients survive the translation to the Responses/Codex upstream (#5933). +function mapChatResponseFormatToResponsesText(body: JsonRecord, result: JsonRecord): void { + const responseFormat = toRecord(body.response_format); + if (responseFormat.type !== "json_schema") return; + + const jsonSchema = toRecord(responseFormat.json_schema); + if (jsonSchema.schema === undefined) return; + + const existingText = toRecord(result.text); + const format: JsonRecord = { + type: "json_schema", + name: toString(jsonSchema.name, "codex_output_schema"), + schema: jsonSchema.schema, + }; + if (jsonSchema.description !== undefined) format.description = jsonSchema.description; + if (jsonSchema.strict !== undefined) format.strict = jsonSchema.strict; + + result.text = { ...existingText, format }; +} + +export function openaiToOpenAIResponsesRequest( + model: unknown, + body: unknown, + stream: unknown, + credentials: unknown +): unknown { + void stream; + + const root = toRecord(body); + const credentialRecord = toRecord(credentials); + const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const result: JsonRecord = { + model, + input: [], + stream: true, + }; + if (!storeEnabled) { + result.store = false; + } + + const input = result.input as JsonRecord[]; + + // Extract first system message as instructions + let hasSystemMessage = false; + const messages = toArray(root.messages); + + for (const messageValue of messages) { + const msg = toRecord(messageValue); + const role = toString(msg.role); + + if (role === "system" || role === "developer") { + if (!hasSystemMessage) { + result.instructions = typeof msg.content === "string" ? msg.content : ""; + hasSystemMessage = true; + } + continue; + } + + // Convert user messages + if (role === "user") { + const content = + typeof msg.content === "string" + ? [{ type: "input_text", text: msg.content }] + : Array.isArray(msg.content) + ? msg.content.map((contentValue) => { + const contentItem = toRecord(contentValue); + if (contentItem.type === "text") { + return { type: "input_text", text: toString(contentItem.text) }; + } + if (contentItem.type === "image_url") { + const imgUrl = contentItem.image_url as + string | { url?: string; detail?: string }; + const imgResult: JsonRecord = { + type: "input_image", + image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "", + }; + if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) { + imgResult.detail = imgUrl.detail; + } + return imgResult; + } + if ( + contentItem.type === "image" && + typeof contentItem.image === "string" && + /^data:([^;]+);base64,(.+)$/.test(contentItem.image) + ) { + // AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330) + const imgResult: JsonRecord = { + type: "input_image", + image_url: contentItem.image, + detail: contentItem.detail !== undefined ? contentItem.detail : "auto", + }; + return imgResult; + } + if (contentItem.type === "file" || contentItem.type === "document") { + // Accept both the OpenAI `file` shape and the Gemini-style `document` shape, + // and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses + // regardless of which content-part name the client used (#2515). + const file = toRecord( + contentItem.type === "document" ? contentItem.document : contentItem.file + ); + const fileResult: JsonRecord = { type: "input_file" }; + if (file.file_data !== undefined) fileResult.file_data = file.file_data; + else if (file.data !== undefined) fileResult.file_data = file.data; + if (file.file_id !== undefined) fileResult.file_id = file.file_id; + if (file.file_url !== undefined) fileResult.file_url = file.file_url; + else if (file.url !== undefined) fileResult.file_url = file.url; + if (file.filename !== undefined) fileResult.filename = file.filename; + else if (file.name !== undefined) fileResult.filename = file.name; + return fileResult; + } + return contentValue; + }) + : [{ type: "input_text", text: "" }]; + + input.push({ + type: "message", + role: "user", + content, + }); + } + + // Convert assistant messages + if (role === "assistant") { + // Skip reasoning_content — OpenAI Responses API requires server-generated + // rs_* IDs for reasoning items. Synthesizing client-side IDs (e.g. reasoning_N) + // causes 400 errors from Responses-compatible upstreams. (#224) + + // Skip thinking blocks in array content — same rs_* ID constraint applies + + // Build assistant output content + const outputContent: unknown[] = []; + if (typeof msg.content === "string" && msg.content) { + outputContent.push({ type: "output_text", text: msg.content }); + } else if (Array.isArray(msg.content)) { + for (const contentValue of msg.content) { + const contentItem = toRecord(contentValue); + if (contentItem.type === "text") { + outputContent.push({ type: "output_text", text: toString(contentItem.text) }); + } else if (contentItem.type === "image_url") { + const url = imageUrlToText(contentItem.image_url); + outputContent.push({ type: "output_text", text: url ? `[Image: ${url}]` : "[Image]" }); + } else if (contentItem.type === "thinking" || contentItem.type === "redacted_thinking") { + // Reasoning already moved above + continue; + } else { + outputContent.push(contentValue); + } + } + } + + // Only add assistant message if content exists + if (outputContent.length > 0) { + input.push({ + type: "message", + role: "assistant", + content: outputContent, + }); + } + + // Convert tool_calls to function_call items + if (Array.isArray(msg.tool_calls)) { + for (const toolCallValue of msg.tool_calls) { + const toolCall = toRecord(toolCallValue); + const fn = toRecord(toolCall.function); + // Skip tool calls with empty names to avoid infinite placeholder_tool loops + const fnName = toString(fn.name).trim(); + if (!fnName) { + continue; + } + input.push({ + type: "function_call", + call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), + name: fnName, + arguments: toString(fn.arguments, "{}"), + }); + } + } + + // Handle deprecated function_call field (pre-tool_calls API) + if (msg.function_call && !msg.tool_calls) { + const fc = toRecord(msg.function_call); + const fnName = toString(fc.name).trim(); + if (fnName) { + input.push({ + type: "function_call", + call_id: clampCallId(`call_${fnName}`), + name: fnName, + arguments: toString(fc.arguments, "{}"), + }); + } + } + } + + // Convert tool results + if (role === "tool") { + input.push({ + type: "function_call_output", + call_id: clampCallId(toString(msg.tool_call_id)), + output: + typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((c) => { + const part = toRecord(c); + if (part.type === "text") + return { type: "input_text", text: toString(part.text) }; + return c; + }) + : String(msg.content ?? ""), + }); + } + + // Handle deprecated function role messages + if (role === "function") { + input.push({ + type: "function_call_output", + call_id: clampCallId(`call_${toString(msg.name)}`), + output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), + }); + } + } + + // Filter orphaned function_call_output items (no matching function_call) + // This happens when Claude Code compaction removes messages but leaves tool results + const knownCallIds = new Set( + input + .filter( + (item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id + ) + .map((item: { type?: string; call_id?: string }) => item.call_id) + ); + result.input = input.filter((item: { type?: string; call_id?: string }) => { + if (item.type === "function_call_output" && item.call_id) { + return knownCallIds.has(item.call_id); + } + return true; + }); + + // If no system message, keep empty instructions + if (!hasSystemMessage) { + result.instructions = ""; + } + + // Convert tools format + if (Array.isArray(root.tools)) { + result.tools = root.tools.map((toolValue) => { + const tool = toRecord(toolValue); + if (tool.type === "function") { + const fn = toRecord(tool.function); + const name = toString(fn.name); + return { + type: "function", + name, + description: toString(fn.description), + parameters: fn.parameters, + strict: fn.strict, + }; + } + return toolValue; + }); + } + + // Translate tool_choice: Chat {type,function:{name}} → Responses {type,name} + if (root.tool_choice !== undefined) { + if (typeof root.tool_choice === "string") { + result.tool_choice = root.tool_choice; + } else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) { + const tc = toRecord(root.tool_choice); + if (tc.type === "function" && tc.function) { + const fn = toRecord(tc.function); + result.tool_choice = { type: "function", name: fn.name }; + } else { + result.tool_choice = root.tool_choice; + } + } else { + result.tool_choice = root.tool_choice; + } + } + + // Pass through relevant fields + if (root.previous_response_id !== undefined) { + result.previous_response_id = root.previous_response_id; + } + if (root.prompt_cache_key !== undefined) { + result.prompt_cache_key = root.prompt_cache_key; + } + if (root.session_id !== undefined) { + result.session_id = root.session_id; + } + if (root.conversation_id !== undefined) { + result.conversation_id = root.conversation_id; + } + if (root.service_tier !== undefined) result.service_tier = root.service_tier; + if (root.temperature !== undefined) result.temperature = root.temperature; + // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. + // The Responses API does not accept max_tokens or max_completion_tokens; it requires + // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. + if (root.max_completion_tokens !== undefined) { + result.max_output_tokens = root.max_completion_tokens; + } else if (root.max_tokens !== undefined) { + result.max_output_tokens = root.max_tokens; + } + if (root.top_p !== undefined) result.top_p = root.top_p; + mapChatResponseFormatToResponsesText(root, result); + // GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`. + const chatVerbosity = normalizeVerbosity(root.verbosity); + if (chatVerbosity) { + result.text = { ...toRecord(result.text), verbosity: chatVerbosity }; + } + if (root.reasoning !== undefined) { + result.reasoning = root.reasoning; + } else if (root.reasoning_effort !== undefined) { + const effort = normalizeResponsesReasoningEffort(root.reasoning_effort); + if (effort) { + result.reasoning = { effort }; + } + } + + // Propagate Responses-API-only fields when a chat client sent them. + // Without this, e.g. `include: ["reasoning.encrypted_content"]` is lost on + // the way upstream and Codex returns an empty reasoning summary, so clients + // (OpenCode, Cursor, etc.) see no thinking stream. + if (Array.isArray(root.include) && root.include.length > 0) { + result.include = root.include; + } + if (storeEnabled) { + if (root[RESPONSES_STORE_MARKER] !== undefined) { + result.store = root[RESPONSES_STORE_MARKER]; + } else if (root.store !== undefined) { + result.store = root.store; + } + } + + return result; +} diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2180bef00e..078d48e94a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -6,8 +6,8 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; // Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. @@ -36,93 +36,9 @@ function applyCopilotSummarizedThinkingDisplay( }; } -// Anthropic constraints for the thinking + max_tokens contract: -// - thinking.budget_tokens must be >= 1024 when thinking is enabled -// - max_tokens must be > thinking.budget_tokens (covers thinking + response) -// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) -const MIN_CLAUDE_THINKING_BUDGET = 1024; -const MIN_RESPONSE_ROOM = 1024; - -function safeCapMaxOutputTokens(model: string): number | null { - try { - const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : null; - } catch { - return null; - } -} - -/** - * Fit Claude thinking budget within the model's max output cap. - * - * Replaces the previous unconditional `max_tokens = budget + 8192` inflation, - * which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and - * trigger HTTP 400 from Anthropic ("max_tokens > 128000"). - * - * Strategy (preserves caller intent up to the model cap): - * - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM) - * - Target max_tokens = responseRoom + requestedBudget, capped at modelCap - * - fittedBudget = max_tokens - responseRoom (the thinking budget actually used) - * - If the cap squeezes fittedBudget below the Anthropic minimum, retry with - * responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable - * thinking entirely (cap too tight for any reasoning). - * - * Worked example (real-world Opus 4.7 case that previously 400'd): - * caller max_tokens = 32000, reasoning_effort=high → budget = 131072, - * model cap = 128000. - * responseRoom = max(32000, 1024) = 32000 - * target = min(32000 + 131072, 128000) = 128000 - * fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK) - * → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072). - */ -export function fitThinkingToMaxTokens( - model: string, - callerMaxTokens: number, - thinking: Record | undefined -): { maxTokens: number; thinking: Record | undefined } { - const modelCap = safeCapMaxOutputTokens(model); - const requestedBudget = Number(thinking?.budget_tokens) || 0; - - // No budgeted thinking — just cap max_tokens to the model output ceiling. - if (!thinking || requestedBudget <= 0) { - return { - maxTokens: - modelCap === null - ? Math.max(callerMaxTokens, 1) - : Math.min(Math.max(callerMaxTokens, 1), modelCap), - thinking, - }; - } - - let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = - modelCap === null - ? responseRoom + requestedBudget - : Math.min(responseRoom + requestedBudget, modelCap); - let fittedBudget = target - responseRoom; - - // If the cap squeezed thinking below Anthropic's floor, try shrinking - // response room to MIN_RESPONSE_ROOM to recover budget. - if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { - responseRoom = MIN_RESPONSE_ROOM; - target = - modelCap === null - ? responseRoom + requestedBudget - : Math.min(responseRoom + requestedBudget, modelCap); - fittedBudget = target - responseRoom; - } - - // Cap too tight for any thinking — disable rather than send an invalid request. - if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { - return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; - } - - const adjustedThinking: Record = { ...thinking }; - if (fittedBudget < requestedBudget) { - adjustedThinking.budget_tokens = fittedBudget; - } - return { maxTokens: target, thinking: adjustedThinking }; -} +// Thinking-budget fitting extracted to a pure leaf; re-exported for external +// importers (tests). Host also uses fitThinkingToMaxTokens internally. +export { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; type ClaudeContentBlock = Record; type ClaudeMessage = { @@ -249,6 +165,97 @@ export function openaiToClaudeRequest(model, body, stream) { result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } + // Thinking configuration + // NOTE: computed BEFORE message-block conversion (below) so that + // `getContentBlocksFromMessage` knows whether the outbound request actually has + // extended thinking enabled — required to correctly gate the `redacted_thinking` + // replay-placeholder injection (#5945). This block has no dependency on + // `result.messages`/`toolNameMap`, so moving it earlier is safe. + if (body.thinking) { + result.thinking = { + type: body.thinking.type || "enabled", + ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), + ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), + }; + } else if (body.reasoning_effort) { + // Convert OpenAI reasoning_effort to Claude thinking format (#627) + // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible + const requestedEffort = String(body.reasoning_effort).toLowerCase(); + const normalizedEffort = + requestedEffort === "max" && !supportsClaudeMaxEffort(model) + ? "high" + : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) + ? "high" + : requestedEffort; + if (isAdaptiveThinkingOnly(model)) { + // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` + // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + + // output_config.effort instead of the budget buckets below. Unrecognized levels + // leave thinking unset so the model keeps its adaptive default rather than 400ing + // on an invalid effort value. + if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } + } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } else { + const effortBudgetMap: Record = { + low: 1024, + medium: 10240, + high: 131072, + max: 131072, + }; + const budget = effortBudgetMap[normalizedEffort]; + if (budget !== undefined && budget > 0) { + result.thinking = { + type: "enabled", + budget_tokens: budget, + }; + } + } + } + + // Fit thinking budget within the model's output cap and ensure + // max_tokens > budget_tokens for all thinking configurations (#627). + // Replaces the previous unconditional `budget + 8192` inflation, which + // could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger + // HTTP 400 from Anthropic. + const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking); + result.max_tokens = fitted.maxTokens; + if (fitted.thinking === undefined) { + delete result.thinking; + } else { + result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); + } + + delete result[COPILOT_REASONING_SUMMARY_MARKER]; + + // Final guard: Claude rejects `temperature` whenever extended thinking is + // enabled. If `result.thinking` was set above from `body.thinking` or + // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature + // defensively. The model-name strip earlier already covers Claude OAuth's + // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). + if (result.thinking && result.temperature !== undefined) { + delete result.temperature; + } + + // Whether the OUTBOUND request actually has extended thinking enabled. Anthropic's + // schema only requires a precursor thinking/redacted_thinking block before a tool_use + // block when thinking mode is active for THIS request — never unconditionally (#5945). + const thinkingEnabledForRequest = Boolean(result.thinking) && result.thinking.type !== "disabled"; + // Messages const systemParts = []; @@ -283,7 +290,12 @@ export function openaiToClaudeRequest(model, body, stream) { for (const msg of nonSystemMessages) { const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; - const blocks = getContentBlocksFromMessage(msg, toolNameMap, disableToolPrefix); + const blocks = getContentBlocksFromMessage( + msg, + toolNameMap, + disableToolPrefix, + thinkingEnabledForRequest + ); const hasToolUse = blocks.some((b) => b.type === "tool_use"); const hasToolResult = blocks.some((b) => b.type === "tool_result"); @@ -471,87 +483,6 @@ export function openaiToClaudeRequest(model, body, stream) { : [{ type: "text", text: String(body.system) }]; } - // Thinking configuration - if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), - }; - } else if (body.reasoning_effort) { - // Convert OpenAI reasoning_effort to Claude thinking format (#627) - // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible - const requestedEffort = String(body.reasoning_effort).toLowerCase(); - const normalizedEffort = - requestedEffort === "max" && !supportsClaudeMaxEffort(model) - ? "high" - : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) - ? "high" - : requestedEffort; - if (isAdaptiveThinkingOnly(model)) { - // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` - // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + - // output_config.effort instead of the budget buckets below. Unrecognized levels - // leave thinking unset so the model keeps its adaptive default rather than 400ing - // on an invalid effort value. - if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } - } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { - result.thinking = { - type: "adaptive", - }; - result.output_config = { - ...(result.output_config || {}), - effort: normalizedEffort, - }; - } else { - const effortBudgetMap: Record = { - low: 1024, - medium: 10240, - high: 131072, - max: 131072, - }; - const budget = effortBudgetMap[normalizedEffort]; - if (budget !== undefined && budget > 0) { - result.thinking = { - type: "enabled", - budget_tokens: budget, - }; - } - } - } - - // Fit thinking budget within the model's output cap and ensure - // max_tokens > budget_tokens for all thinking configurations (#627). - // Replaces the previous unconditional `budget + 8192` inflation, which - // could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger - // HTTP 400 from Anthropic. - const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking); - result.max_tokens = fitted.maxTokens; - if (fitted.thinking === undefined) { - delete result.thinking; - } else { - result.thinking = applyCopilotSummarizedThinkingDisplay(fitted.thinking, body); - } - - delete result[COPILOT_REASONING_SUMMARY_MARKER]; - - // Final guard: Claude rejects `temperature` whenever extended thinking is - // enabled. If `result.thinking` was set above from `body.thinking` or - // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature - // defensively. The model-name strip earlier already covers Claude OAuth's - // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). - if (result.thinking && result.temperature !== undefined) { - delete result.temperature; - } - // Attach toolNameMap to result for response translation if (toolNameMap.size > 0) { result._toolNameMap = toolNameMap; @@ -572,7 +503,12 @@ export function openaiToClaudeRequest(model, body, stream) { } // Get content blocks from single message -function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPrefix = false) { +function getContentBlocksFromMessage( + msg, + toolNameMap = new Map(), + disableToolPrefix = false, + thinkingEnabledForRequest = false +) { const blocks = []; if (msg.role === "tool") { @@ -639,22 +575,6 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } else if (msg.role === "assistant") { - // Add reasoning_content as a replay placeholder (OpenAI extended thinking format). - // #5312 RC-D: reasoning_content carries NO real Claude signature. Emitting a - // `thinking` block with the fabricated DEFAULT signature makes Anthropic reject the - // replay with 400 "Invalid signature in thinking block" — and claudeHelper's - // latest-assistant guard (prepareClaudeRequest) preserves it verbatim, so the fake - // signature leaks upstream. Emit a signature-less redacted_thinking block instead - // (the same shape prepareClaudeRequest produces for Anthropic-native replay); - // Anthropic accepts it without signature validation and non-Anthropic Claude-shape - // upstreams re-hydrate the real text downstream from reasoningCache. - if (msg.reasoning_content) { - blocks.push({ - type: "redacted_thinking", - data: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); - } - if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === "text" && part.text) { @@ -703,6 +623,37 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr } } } + + // Add reasoning_content as a replay placeholder (OpenAI extended thinking format) — + // ONLY when Anthropic's schema actually requires a precursor thinking block: the + // outbound request has extended thinking enabled AND this assistant turn contains a + // tool_use block (Anthropic rejects a tool_use turn without a preceding + // thinking/redacted_thinking block when thinking is active). #5312 RC-D: + // reasoning_content carries NO real Claude signature. Emitting a `thinking` block + // with the fabricated DEFAULT signature makes Anthropic reject the replay with 400 + // "Invalid signature in thinking block" — and claudeHelper's latest-assistant guard + // (prepareClaudeRequest) preserves it verbatim, so the fake signature leaks + // upstream. Emit a signature-less redacted_thinking block instead (the same shape + // prepareClaudeRequest produces for Anthropic-native replay, gated the same way at + // claudeHelper.ts `thinkingEnabled && !hasThinking && hasToolUse`); Anthropic + // accepts it without signature validation and non-Anthropic Claude-shape upstreams + // re-hydrate the real text downstream from reasoningCache. + // #5945: injecting this unconditionally — for ANY assistant turn carrying + // reasoning_content, regardless of tool_use or thinking state — fabricates a content + // block the client never sent. Some upstream clients (reported: Claude Sonnet 5 via + // the "Pi" harness) detect the extra block and refuse the turn as prompt injection. + // Drop reasoning_content silently when it is not required by the schema, mirroring + // how other echo-only fields are dropped (see OPENAI_INCOMPATIBLE_ECHO_FIELDS). + const hasThinkingBlock = blocks.some( + (b) => b.type === "thinking" || b.type === "redacted_thinking" + ); + const hasToolUseBlock = blocks.some((b) => b.type === "tool_use"); + if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) { + blocks.unshift({ + type: "redacted_thinking", + data: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); + } } return blocks; diff --git a/open-sse/translator/request/openai-to-claude/thinkingBudget.ts b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts new file mode 100644 index 0000000000..e78275570f --- /dev/null +++ b/open-sse/translator/request/openai-to-claude/thinkingBudget.ts @@ -0,0 +1,89 @@ +import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts"; + +// Anthropic constraints for the thinking + max_tokens contract: +// - thinking.budget_tokens must be >= 1024 when thinking is enabled +// - max_tokens must be > thinking.budget_tokens (covers thinking + response) +// - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) +const MIN_CLAUDE_THINKING_BUDGET = 1024; +const MIN_RESPONSE_ROOM = 1024; + +function safeCapMaxOutputTokens(model: string): number | null { + try { + const cap = capMaxOutputTokens(model); + return typeof cap === "number" && cap > 0 ? cap : null; + } catch { + return null; + } +} + +/** + * Fit Claude thinking budget within the model's max output cap. + * + * Replaces the previous unconditional `max_tokens = budget + 8192` inflation, + * which could exceed the model output cap (e.g. Opus 4.7's 128000 ceiling) and + * trigger HTTP 400 from Anthropic ("max_tokens > 128000"). + * + * Strategy (preserves caller intent up to the model cap): + * - Preserve caller's max_tokens as response room (floored to MIN_RESPONSE_ROOM) + * - Target max_tokens = responseRoom + requestedBudget, capped at modelCap + * - fittedBudget = max_tokens - responseRoom (the thinking budget actually used) + * - If the cap squeezes fittedBudget below the Anthropic minimum, retry with + * responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable + * thinking entirely (cap too tight for any reasoning). + * + * Worked example (real-world Opus 4.7 case that previously 400'd): + * caller max_tokens = 32000, reasoning_effort=high → budget = 131072, + * model cap = 128000. + * responseRoom = max(32000, 1024) = 32000 + * target = min(32000 + 131072, 128000) = 128000 + * fittedBudget = 128000 - 32000 = 96000 (>= 1024, OK) + * → max_tokens=128000, budget_tokens=96000 (vs. the old buggy 139264 / 131072). + */ +export function fitThinkingToMaxTokens( + model: string, + callerMaxTokens: number, + thinking: Record | undefined +): { maxTokens: number; thinking: Record | undefined } { + const modelCap = safeCapMaxOutputTokens(model); + const requestedBudget = Number(thinking?.budget_tokens) || 0; + + // No budgeted thinking — just cap max_tokens to the model output ceiling. + if (!thinking || requestedBudget <= 0) { + return { + maxTokens: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), + thinking, + }; + } + + let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); + let target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); + let fittedBudget = target - responseRoom; + + // If the cap squeezed thinking below Anthropic's floor, try shrinking + // response room to MIN_RESPONSE_ROOM to recover budget. + if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { + responseRoom = MIN_RESPONSE_ROOM; + target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); + fittedBudget = target - responseRoom; + } + + // Cap too tight for any thinking — disable rather than send an invalid request. + if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { + return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; + } + + const adjustedThinking: Record = { ...thinking }; + if (fittedBudget < requestedBudget) { + adjustedThinking.budget_tokens = fittedBudget; + } + return { maxTokens: target, thinking: adjustedThinking }; +} diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 7a4c794b0d..fc6d39abfd 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -5,6 +5,11 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; +import { + parseToolInput, + normalizeKiroToolSchema, + serializeToolResultContent, +} from "./openai-to-kiro/messageHelpers.ts"; /** * Anthropic's direct-provider `[1m]` context-1m beta suffix. Kiro is AWS @@ -23,116 +28,17 @@ export const KIRO_UNSUPPORTED_CONTEXT_1M_MESSAGE = */ export function hasUnsupportedKiroContextSuffix(model: unknown): boolean { return ( - typeof model === "string" && - model.toLowerCase().includes(KIRO_UNSUPPORTED_CONTEXT_1M_SUFFIX) + typeof model === "string" && model.toLowerCase().includes(KIRO_UNSUPPORTED_CONTEXT_1M_SUFFIX) ); } -function parseToolInput(value: unknown) { - if (value && typeof value === "object" && !Array.isArray(value)) { - return value; - } - if (typeof value !== "string") { - return {}; - } - - const trimmed = value.trim(); - if (!trimmed) { - return {}; - } - - try { - const parsed = JSON.parse(trimmed); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - /** - * Recursively sanitize JSON Schema for Kiro API. - * Kiro returns 400 "Improperly formed request" if: - * - `required` is an empty array [] - * - `additionalProperties` is present anywhere + * 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 normalizeKiroToolSchema(schema: unknown): Record { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - return { type: "object", properties: {} }; - } - - const result: Record = {}; - const src = schema as Record; - - for (const [key, value] of Object.entries(src)) { - // Skip empty required arrays — Kiro rejects them - if (key === "required" && Array.isArray(value) && value.length === 0) { - continue; - } - // Skip additionalProperties — Kiro doesn't support it - if (key === "additionalProperties") { - continue; - } - // Recursively process nested objects - if ( - key === "properties" && - typeof value === "object" && - value !== null && - !Array.isArray(value) - ) { - const sanitizedProps: Record = {}; - for (const [propName, propValue] of Object.entries(value as Record)) { - sanitizedProps[propName] = normalizeKiroToolSchema(propValue); - } - result[key] = sanitizedProps; - } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { - result[key] = normalizeKiroToolSchema(value); - } else if (Array.isArray(value)) { - result[key] = value.map((item) => - typeof item === "object" && item !== null && !Array.isArray(item) - ? normalizeKiroToolSchema(item) - : item - ); - } else { - result[key] = value; - } - } - - return result; -} - -function serializeToolResultContent(content: unknown): string { - if (typeof content === "string") { - return content || "(no output)"; - } - if (!Array.isArray(content)) { - if (content !== null && content !== undefined) { - try { - return JSON.stringify(content); - } catch { - return "(no output)"; - } - } - return "(no output)"; - } - const parts: string[] = []; - for (const block of content as Array>) { - if (!block || typeof block !== "object") continue; - if (block.type === "text" && typeof block.text === "string") { - if (block.text) parts.push(block.text); - } else if (block.type === "image" || block.type === "image_url") { - const src = block.source as Record | undefined; - const mediaType = src?.media_type ?? block.media_type ?? "image"; - parts.push(`[image: ${mediaType}]`); - } else { - try { - const str = JSON.stringify(block); - if (str && str !== "{}") parts.push(str); - } catch { - // skip unserializable block - } - } - } - return parts.join("\n") || "(no output)"; +function wrapSystemReminder(text: string): string { + return `\n${text}\n`; } /** @@ -341,7 +247,11 @@ function convertMessages(messages, tools, model) { content: [{ text: toolContent }], }); } else if (content) { - pendingUserContent.push(content); + // #2306: Kiro/CodeWhisperer has no `system` role, so system messages are + // normalized to `user`. Wrap their content in tags so + // the model can tell the system prompt apart from real user input instead + // of treating the full Claude Code prompt as something the user typed. + pendingUserContent.push(msg.role === "system" ? wrapSystemReminder(content) : content); } } else if (role === "assistant") { // Extract text content and tool uses @@ -806,9 +716,7 @@ export function buildKiroPayload(model, body, stream, credentials) { // compressContext runs). This keeps conversationId stable even when compression alters content. // Priority 2: Deterministic hash from first user message in translated history (fallback). const preCompressionBody = credentials?._preCompressionBody as - | Record - | null - | undefined; + Record | null | undefined; const preCompressionMessages = Array.isArray(preCompressionBody?.messages) ? preCompressionBody.messages : null; diff --git a/open-sse/translator/request/openai-to-kiro/messageHelpers.ts b/open-sse/translator/request/openai-to-kiro/messageHelpers.ts new file mode 100644 index 0000000000..2102023fa7 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro/messageHelpers.ts @@ -0,0 +1,109 @@ +// Pure message/tool helpers for the OpenAI -> Kiro request translator. +// Extracted verbatim from openai-to-kiro.ts (no host imports). + +export function parseToolInput(value: unknown) { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + if (typeof value !== "string") { + return {}; + } + + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Recursively sanitize JSON Schema for Kiro API. + * Kiro returns 400 "Improperly formed request" if: + * - `required` is an empty array [] + * - `additionalProperties` is present anywhere + */ +export function normalizeKiroToolSchema(schema: unknown): Record { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return { type: "object", properties: {} }; + } + + const result: Record = {}; + const src = schema as Record; + + for (const [key, value] of Object.entries(src)) { + // Skip empty required arrays — Kiro rejects them + if (key === "required" && Array.isArray(value) && value.length === 0) { + continue; + } + // Skip additionalProperties — Kiro doesn't support it + if (key === "additionalProperties") { + continue; + } + // Recursively process nested objects + if ( + key === "properties" && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const sanitizedProps: Record = {}; + for (const [propName, propValue] of Object.entries(value as Record)) { + sanitizedProps[propName] = normalizeKiroToolSchema(propValue); + } + result[key] = sanitizedProps; + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = normalizeKiroToolSchema(value); + } else if (Array.isArray(value)) { + result[key] = value.map((item) => + typeof item === "object" && item !== null && !Array.isArray(item) + ? normalizeKiroToolSchema(item) + : item + ); + } else { + result[key] = value; + } + } + + return result; +} + +export function serializeToolResultContent(content: unknown): string { + if (typeof content === "string") { + return content || "(no output)"; + } + if (!Array.isArray(content)) { + if (content !== null && content !== undefined) { + try { + return JSON.stringify(content); + } catch { + return "(no output)"; + } + } + return "(no output)"; + } + const parts: string[] = []; + for (const block of content as Array>) { + if (!block || typeof block !== "object") continue; + if (block.type === "text" && typeof block.text === "string") { + if (block.text) parts.push(block.text); + } else if (block.type === "image" || block.type === "image_url") { + const src = block.source as Record | undefined; + const mediaType = src?.media_type ?? block.media_type ?? "image"; + parts.push(`[image: ${mediaType}]`); + } else { + try { + const str = JSON.stringify(block); + if (str && str !== "{}") parts.push(str); + } catch { + // skip unserializable block + } + } + } + return parts.join("\n") || "(no output)"; +} diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index dbf608b148..9d8a8629fe 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -9,6 +9,7 @@ import { containsTextualToolCallMarker, } from "../../utils/textualToolCall.ts"; import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; +import { stripAnsiCodes } from "../../utils/streamHelpers.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -401,6 +402,10 @@ export function geminiToOpenAIResponse(chunk, state) { // Process parts if (content?.parts) { for (const part of content.parts) { + // Normalize the part text once: strip ANSI/VT100 escape codes that some + // upstreams (gemini-cli terminal redraws) inject, so the `` / + // `[Tool call:]` textual parsers below never see stray control bytes (#2273). + const partText = stripAnsiCodes(part.text); const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; if (hasThoughtSig && typeof hasThoughtSig === "string") { @@ -409,7 +414,7 @@ export function geminiToOpenAIResponse(chunk, state) { // Handle thought signature (thinking mode) or native gemini thought flag if (hasThoughtSig || isThought) { - const hasTextContent = part.text !== undefined && part.text !== ""; + const hasTextContent = partText !== undefined && partText !== ""; const hasFunctionCall = !!part.functionCall; // Gemini/Antigravity can emit thoughtSignature as a standalone part @@ -433,7 +438,7 @@ export function geminiToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: isThought ? { reasoning_content: part.text } : { content: part.text }, + delta: isThought ? { reasoning_content: partText } : { content: partText }, finish_reason: null, }, ], @@ -463,10 +468,10 @@ export function geminiToOpenAIResponse(chunk, state) { // "[Tool call: ...]" block instead of native functionCall. Convert that // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. - if (part.text !== undefined && part.text !== "") { + if (partText !== undefined && partText !== "") { const afterReasoning = parseTextualReasoningTags - ? consumeTextualReasoningTags(part.text, state, results) - : part.text; + ? consumeTextualReasoningTags(partText, state, results) + : partText; if (!afterReasoning) continue; let accumulated = (state.textualToolCallBuffer || "") + afterReasoning; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 3a9a1299da..6e62715f84 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -7,38 +7,16 @@ import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; +import { + normalizeToolName, + stripEmptyOptionalToolArgs, + normalizeOutputIndex, + normalizeUpstreamFailure, + extractResponsesReasoningSummaryText, +} from "./openai-responses/pureHelpers.ts"; -function normalizeToolName(value) { - return typeof value === "string" ? value.trim() : ""; -} - -function stripEmptyOptionalToolArgs(value, toolName) { - if (value == null) return value; - - if (typeof value === "string") { - // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. - // For arbitrary tools, empty strings/arrays may be valid user payloads. - if (toolName !== "Read") return value; - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; - const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); - return JSON.stringify(cleaned ?? {}); - } catch { - return value; - } - } - - if (Array.isArray(value) || typeof value !== "object") return value; - - const cleaned = { ...value }; - for (const [key, entry] of Object.entries(cleaned)) { - if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { - delete cleaned[key]; - } - } - return cleaned; -} +// normalizeUpstreamFailure is re-exported for external importers (tests). +export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; /** * Translate OpenAI chunk to Responses API events @@ -192,11 +170,6 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } // Normalize output_index to a non-negative integer (replaces fragile parseInt calls) -function normalizeOutputIndex(outputIndex) { - const normalized = Number(outputIndex); - return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; -} - // Record a finalized item keyed by output_index so buildDenseOutput can sort later function recordCompletedItem(state, outputIndex, item) { if (!Array.isArray(state.completedOutputItems)) { @@ -564,50 +537,6 @@ function flushEvents(state) { return events; } -export function normalizeUpstreamFailure(data, fallbackType = "server_error") { - const response = data?.response && typeof data.response === "object" ? data.response : null; - const error = - response?.error && typeof response.error === "object" - ? response.error - : data?.error && typeof data.error === "object" - ? data.error - : null; - - const code = typeof error?.code === "string" ? error.code : ""; - const message = - typeof error?.message === "string" - ? error.message - : typeof data?.message === "string" - ? data.message - : "Upstream failure"; - - // Preserve upstream error semantics: - // - 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"; - let status: number; - let type: string; - if (isRateLimit) { - status = 429; - type = "rate_limit_error"; - } else if (isContextOverflow) { - status = 400; - type = "invalid_request_error"; - } else { - status = 502; - type = fallbackType; - } - - return { - status, - type, - code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), - message, - }; -} - /** * OpenAI Chat Completions streams announce the assistant role on the FIRST delta * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", @@ -680,15 +609,6 @@ function buildResponsesReasoningDeltaChunk(state, text) { }; } -function extractResponsesReasoningSummaryText(item) { - if (!item || !Array.isArray(item.summary)) return ""; - return item.summary - .map((part) => - part && typeof part === "object" && typeof part.text === "string" ? part.text : "" - ) - .join(""); -} - /** * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts new file mode 100644 index 0000000000..3ed559fb55 --- /dev/null +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -0,0 +1,92 @@ +// Pure, stateless helpers for the OpenAI Responses <-> Chat response translator. +// Extracted verbatim from response/openai-responses.ts (no host imports, no stream state). + +export function normalizeToolName(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function stripEmptyOptionalToolArgs(value, toolName) { + if (value == null) return value; + + if (typeof value === "string") { + // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. + // For arbitrary tools, empty strings/arrays may be valid user payloads. + if (toolName !== "Read") return value; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; + const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); + return JSON.stringify(cleaned ?? {}); + } catch { + return value; + } + } + + if (Array.isArray(value) || typeof value !== "object") return value; + + const cleaned = { ...value }; + for (const [key, entry] of Object.entries(cleaned)) { + if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { + delete cleaned[key]; + } + } + return cleaned; +} + +export function normalizeOutputIndex(outputIndex) { + const normalized = Number(outputIndex); + return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; +} + +export function normalizeUpstreamFailure(data, fallbackType = "server_error") { + const response = data?.response && typeof data.response === "object" ? data.response : null; + const error = + response?.error && typeof response.error === "object" + ? response.error + : data?.error && typeof data.error === "object" + ? data.error + : null; + + const code = typeof error?.code === "string" ? error.code : ""; + const message = + typeof error?.message === "string" + ? error.message + : typeof data?.message === "string" + ? data.message + : "Upstream failure"; + + // Preserve upstream error semantics: + // - 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"; + let status: number; + let type: string; + if (isRateLimit) { + status = 429; + type = "rate_limit_error"; + } else if (isContextOverflow) { + status = 400; + type = "invalid_request_error"; + } else { + status = 502; + type = fallbackType; + } + + return { + status, + type, + code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), + message, + }; +} + +export function extractResponsesReasoningSummaryText(item) { + if (!item || !Array.isArray(item.summary)) return ""; + return item.summary + .map((part) => + part && typeof part === "object" && typeof part.text === "string" ? part.text : "" + ) + .join(""); +} diff --git a/open-sse/utils/clinepassEnvelope.ts b/open-sse/utils/clinepassEnvelope.ts new file mode 100644 index 0000000000..925bb4efeb --- /dev/null +++ b/open-sse/utils/clinepassEnvelope.ts @@ -0,0 +1,59 @@ +// ClinePass upstream wraps non-streaming JSON responses in a {success, data} +// envelope (errors use {success: false, error}). Detect and unwrap; pass the +// payload through untouched for every other provider / shape. + +export interface ClinepassEnvelopeError { + message: string; + status: number | null; +} + +export interface ClinepassEnvelopeResult { + body: unknown; + error: ClinepassEnvelopeError | null; +} + +/** + * Unwrap a ClinePass {success, data} envelope. + * + * - Non-clinepass provider, non-object, array, or object without a `success` + * key → pass through untouched ({ body, error: null }). + * - { success: false, ... } → { body: null, error: { message, status } } with + * the upstream error string extracted (never a local stack — the caller must + * still route it through sanitizeErrorMessage before emitting a response). + * - { success: true, data: {...} } → unwrap to `data`. + */ +export function unwrapClinepassEnvelope( + body: unknown, + provider: string | null | undefined +): ClinepassEnvelopeResult { + if (provider !== "clinepass") return { body, error: null }; + if (!body || typeof body !== "object" || Array.isArray(body)) return { body, error: null }; + + const record = body as Record; + if (!("success" in record)) return { body, error: null }; + + if (record.success === false) { + const rawError = record.error; + const message = + typeof rawError === "string" + ? rawError + : (rawError && typeof rawError === "object" + ? ((rawError as Record).message as string | undefined) + : undefined) || + (typeof record.message === "string" ? record.message : undefined) || + "Upstream error"; + const statusCode = typeof record.statusCode === "number" ? record.statusCode : null; + return { body: null, error: { message, status: statusCode } }; + } + + if ( + record.success === true && + "data" in record && + record.data !== null && + typeof record.data === "object" + ) { + return { body: record.data, error: null }; + } + + return { body, error: null }; +} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 17bbef99d4..9afc237715 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "./cors.ts"; +import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; @@ -230,7 +231,14 @@ export async function parseUpstreamError(response: Response, provider: string | const parsed = JSON.parse(text); // Handle array responses (e.g., from some Gemini APIs) const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {}; - message = json.error?.message || json.message || json.error || text; + // ClinePass wraps upstream errors in a {success:false, error} envelope. + // Extract the upstream error string (an upstream JSON field, not a local + // stack) — still routed through sanitizeErrorMessage/buildErrorBody by + // every consumer below (Rule #12). + const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider); + message = clinepassEnvError + ? clinepassEnvError.message + : json.error?.message || json.message || json.error || text; errorCode = json.error?.code || json.code; errorType = json.error?.type || json.type; } catch { @@ -497,7 +505,8 @@ export function formatProviderError( const message = error.message || "Unknown error"; // Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures const cause = (error as { cause?: unknown }).cause; - const causeObj = cause && typeof cause === "object" ? (cause as Record) : undefined; + const causeObj = + cause && typeof cause === "object" ? (cause as Record) : undefined; const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined; const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined; const causeStr = diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index bfa5a2aad2..03e0b66f5c 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -53,6 +53,31 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** + * Matches ANSI/VT100 terminal control sequences plus non-whitespace C0 control + * codes, while preserving `\t` (0x09), `\n` (0x0a), and `\r` (0x0d). + * + * Some upstream CLIs (notably gemini-cli via the `gc/` bridge) prefix SSE frames + * with cursor-movement escapes such as `\x1b[2K\x1b[1A` to redraw the terminal. + * Those bytes are not whitespace, so `line.trimStart().startsWith("data:")` fails + * and the frame is silently dropped, stalling the client SSE parser (issue #2273). + * + * The pattern is strictly bounded (no unbounded quantifiers over overlapping + * alternatives) so it runs in linear time on untrusted input — ReDoS-safe. + */ +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_RE = + /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g; + +/** + * Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string. + * Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r. + */ +export function stripAnsiCodes(str: T): T { + if (typeof str !== "string") return str; + return str.replace(ANSI_ESCAPE_RE, "") as T; +} + export function parseSSEDataPayload( data: unknown, options: SSEPayloadOptions = {} @@ -88,15 +113,18 @@ export function parseSSEDataLines( export function parseSSELine(line: string): SSEJsonPayload | null { if (!line) return null; - // Trim leading whitespace before checking field name. + // Trim leading whitespace before checking field name. Also strip ANSI/VT100 + // escape codes so terminal-redraw-prefixed frames (e.g. gemini-cli `\x1b[2K\x1b[1A`) + // still resolve to a `data:` line instead of being silently dropped (#2273). const trimmed = line.trimStart(); - if (!trimmed.startsWith("data:")) return null; + const clean = stripAnsiCodes(trimmed); + if (!clean.startsWith("data:")) return null; - return parseSSEDataPayload(trimmed.slice(5)); + return parseSSEDataPayload(clean.slice(5)); } function extractSseDataLine(line: string): string | null { - const trimmed = line.trimStart().replace(/\r$/, ""); + const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, "")); if (!trimmed.startsWith("data:")) return null; return trimmed.slice(5).trimStart(); } diff --git a/package-lock.json b/package-lock.json index 655662db13..882f93cc12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.43", + "version": "3.8.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.43", + "version": "3.8.44", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -22,6 +22,7 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", + "@toon-format/toon": "^2.3.0", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", "axios": "^1.16.1", @@ -70,6 +71,7 @@ "react-markdown": "^10.1.0", "react-reconciler": "^0.33.0", "recharts": "^3.8.1", + "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "socks": "^2.8.7", "sql.js": "^1.14.1", @@ -28671,7 +28673,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.43", + "version": "3.8.44", "dependencies": { "@toon-format/toon": "^2.3.0", "safe-regex": "^2.1.1" diff --git a/package.json b/package.json index ce571e997c..2004895bd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.43", + "version": "3.8.44", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -95,13 +95,13 @@ "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", - "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts", @@ -191,7 +191,7 @@ "test:mutation": "stryker run", "test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs", "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", - "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx --test tests/unit/*.test.ts", "coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md", @@ -205,7 +205,9 @@ "uninstall:full": "node scripts/build/uninstall.mjs --full", "prepare": "husky", "system-info": "node scripts/dev/system-info.mjs", - "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs" + "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs", + "release:contributors": "node scripts/release/gen-contributors.mjs", + "release:uncovered": "node scripts/release/list-uncovered-commits.mjs" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..72766ea810 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,31 @@ +packages: + - "open-sse" +allowBuilds: + "@parcel/watcher": true + "@swc/core": true + better-sqlite3: true + core-js: true + esbuild: true + keytar: true + koffi: true + libxmljs2: true + onnxruntime-node: true + protobufjs: true + sharp: true + tls-client-node: true + unrs-resolver: true +onlyBuiltDependencies: + - "@parcel/watcher" + - "@swc/core" + - "better-sqlite3" + - "core-js" + - "esbuild" + - "keytar" + - "koffi" + - "libxmljs2" + - "onnxruntime-node" + - "omniroute" + - "protobufjs" + - "sharp" + - "tls-client-node" + - "unrs-resolver" diff --git a/pnpm.json b/pnpm.json new file mode 100644 index 0000000000..b07b72ab41 --- /dev/null +++ b/pnpm.json @@ -0,0 +1,18 @@ +{ + "onlyBuiltDependencies": [ + "@parcel/watcher", + "@swc/core", + "better-sqlite3", + "core-js", + "esbuild", + "keytar", + "koffi", + "libxmljs2", + "omniroute", + "onnxruntime-node", + "protobufjs", + "sharp", + "tls-client-node", + "unrs-resolver" + ] +} diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index d88320ed85..510d5b1f04 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -473,7 +473,16 @@ async function main(): Promise { ...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]), ...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]), ]; - const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8"); + // The combo dispatch was decomposed (Block J): the `strategy === "..."` branches + // now live across combo.ts + its strategy-ordering leaves, so scan all of them. + const comboDispatchFiles = [ + "open-sse/services/combo.ts", + "open-sse/services/combo/applyStrategyOrdering.ts", + "open-sse/services/combo/resolveAutoStrategy.ts", + ]; + const comboSource = comboDispatchFiles + .map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8")) + .join("\n"); const handled = extractHandledStrategies(comboSource); // Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist — diff --git a/scripts/check/check-pr-evidence.mjs b/scripts/check/check-pr-evidence.mjs index f5cbbf8624..41f059d67f 100644 --- a/scripts/check/check-pr-evidence.mjs +++ b/scripts/check/check-pr-evidence.mjs @@ -231,7 +231,16 @@ if (isMain) { } else if (result === "pass") { reportLines.push("Result: PASS", "", reason); } else { - reportLines.push("Result: FAIL", "", reason); + reportLines.push( + "Result: FAIL", + "", + reason, + "", + "> ℹ️ Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " + + "does not listen to the `edited` event. Add the `## Evidence` block, then **push a " + + "commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " + + "the body BEFORE the first push (see the generate-release skill, Phase 0)." + ); } const report = buildReport(reportLines); diff --git a/scripts/check/check-test-discovery.mjs b/scripts/check/check-test-discovery.mjs index 9ea80e0774..4c0efe5ba8 100644 --- a/scripts/check/check-test-discovery.mjs +++ b/scripts/check/check-test-discovery.mjs @@ -53,7 +53,7 @@ export const COLLECTORS = [ // "vitest" e explodem no node runner). Subdir novo: adicione aqui E nos scripts // (o drift-check + o gate de órfãos forçam a manutenção em sincronia). { - glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", + glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", sources: ["package.json", ".github/workflows/ci.yml"], }, // Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda) diff --git a/scripts/quality/build-test-impact-map.mjs b/scripts/quality/build-test-impact-map.mjs index 5952f51e7b..1704c3a356 100644 --- a/scripts/quality/build-test-impact-map.mjs +++ b/scripts/quality/build-test-impact-map.mjs @@ -60,7 +60,7 @@ function sourceDepsOf(entry) { const testFiles = globSync( [ "tests/unit/*.test.ts", - "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts", + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts", ], { cwd: ROOT, absolute: true } ); diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 2914db0b50..78091ce8ab 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -146,7 +146,7 @@ function run(cmd, cmdArgs, opts = {}) { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, - env: { ...process.env, FORCE_COLOR: "0" }, + env: { ...process.env, FORCE_COLOR: "0", ...(opts.env || {}) }, // A hard ceiling for the long, silent test suites (execFileSync buffers all output until // exit, so they show no progress while running). undefined = no timeout for fast gates. ...(opts.timeout ? { timeout: opts.timeout } : {}), @@ -255,6 +255,27 @@ function main() { }); } + // test-masking (hard) — a PR-context gate: it only runs on the release PR (PR→main) in CI, so + // net-assert reductions accrue unseen on release/** and explode on the release PR. Reproduce it + // here against origin/main so a non-allowlisted reduction surfaces in the pre-flight, not in a + // ~40-min CI layer (v3.8.43 cost 3 such round-trips). Legitimate reductions get allowlisted in + // config/quality/test-masking-allowlist.json; tautology/skip/deletion signals are never allowlistable. + if (!QUICK) { + announce("Test-masking (weakened-assert guard vs main)"); + // best-effort fetch so the merge-base diff is accurate; ignore fetch failure (offline pre-flight) + run("git", ["fetch", "--no-tags", "origin", "main", "--depth=200"], { timeout: 60 * 1000 }); + const { code, out } = run(npmCmd, ["run", "check:test-masking"], { + env: { GITHUB_BASE_REF: "main" }, + }); + record({ + id: "test-masking", + label: "Test-masking (weakened-assert guard)", + kind: "hard", + ok: code === 0, + detail: code === 0 ? "no weakening" : firstFailureLine(out), + }); + } + // Remaining quality-gate / quality-extended ratchets that the PR→release // fast-gates skip and that historically surfaced — one at a time, because the // CI Quality Ratchet job is fail-fast — only on the release PR. Running them all diff --git a/scripts/release/gen-contributors.mjs b/scripts/release/gen-contributors.mjs new file mode 100644 index 0000000000..660fbb5893 --- /dev/null +++ b/scripts/release/gen-contributors.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Generate (or inject) the `### 🙌 Contributors` table for a CHANGELOG version section. +// +// WHY: every version's CHANGELOG `## [vX.Y.Z]` section MUST end with a `### 🙌 Contributors` +// table (the convention across every prior version). v3.8.43 shipped without it (a real miss the +// owner caught) because it was assembled by hand. This makes it reproducible + accurate. +// +// A naive `@handle` scan mis-assigns rollup PRs — a maintenance bullet lists many PRs under one +// `— thanks @X`, and a flat scan would credit every handle on the line with all of them. This +// parses each `([#refs] — thanks @X / @Y)` PARENTHETICAL GROUP and assigns that group's refs only +// to that group's handles (crediting is per-parenthetical, matching how bullets are written). +// +// Usage: +// node scripts/release/gen-contributors.mjs # print the table +// node scripts/release/gen-contributors.mjs --inject # insert/replace it in CHANGELOG.md +// +// Exit codes: 0 ok · 2 version section not found · 3 nothing to inject over. + +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)), "..", ".."); + +// Handles that are package names / code refs / scopes, never people. Extend as needed. +export const NOISE_HANDLES = new Set([ + "toon-format", + "dnd-kit", + "om-usage", + "anthropic-ai", + "huggingface", + "oven", + "latest", + "next", + "types", +]); + +const MAINTAINER = "diegosouzapw"; + +/** Extract the `## [version]` … up to the next `## [` section body (exclusive of the next header). */ +export function extractVersionSection(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const bodyStart = sm.index + sm[0].length; + const rest = changelog.slice(bodyStart); + const nextIdx = rest.search(/\n## \[/); + return nextIdx === -1 ? rest : rest.slice(0, nextIdx); +} + +/** + * Parse contributor → set of ref numbers from a version section body. + * Rules (in order, per bullet line starting with "- "): + * 1. Parenthetical groups containing "thanks": refs in the group → handles in the group. + * 2. A "thanks @X" NOT inside such a group (direct-commit trailing credit): the last ref before + * it on the line (if any) → the handles. + * 3. "Extracted from [#N] by [@X]": N → X. + * Excludes NOISE_HANDLES and the maintainer (returned separately by caller). + */ +export function parseContributors(sectionText) { + const agg = new Map(); // handle -> Set(refs) + const add = (handle, refs) => { + if (NOISE_HANDLES.has(handle) || handle === MAINTAINER) return; + if (!agg.has(handle)) agg.set(handle, new Set()); + for (const r of refs) agg.get(handle).add(r); + }; + const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); + + for (const raw of sectionText.split("\n")) { + if (!raw.startsWith("- ")) continue; + // Collapse markdown links so parenthetical groups aren't broken by the URL's own parens: + // [#5720](https://…/pull/5720) → #5720 · [@pizzav-xyz](https://…) → @pizzav-xyz + const line = raw + .replace(/\[#(\d+)\]\([^)]*\)/g, "#$1") + .replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1"); + const usedSpans = []; + + // (1) parenthetical groups with "thanks" + for (const g of line.matchAll(/\(([^()]*thanks[^()]*)\)/g)) { + const inner = g[1]; + const refs = refsIn(inner); + for (const th of inner.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + for (const h of handlesIn(th[1])) add(h, refs); + } + usedSpans.push([g.index, g.index + g[0].length]); + } + + // (2) trailing "— thanks @X" outside any used parenthetical (direct commits) + for (const th of line.matchAll(/thanks\s+((?:@[A-Za-z0-9_-]+(?:\s*\/\s*)?)+)/g)) { + const inGroup = usedSpans.some(([s, e]) => th.index >= s && th.index < e); + if (inGroup) continue; + const before = line.slice(0, th.index); + const refsBefore = refsIn(before); + const refs = refsBefore.length ? [refsBefore[refsBefore.length - 1]] : []; + for (const h of handlesIn(th[1])) add(h, refs); + } + + // (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above) + for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) { + add(em[2], [Number(em[1])]); + } + } + return agg; +} + +export function renderContributors(version, agg, maintainerNote = "maintainer") { + const fmt = (set) => + set.size + ? [...set] + .sort((a, b) => a - b) + .map((n) => `#${n}`) + .join(", ") + : "direct commit / report"; + const rows = [...agg.entries()].sort((a, b) => + a[0].toLowerCase().localeCompare(b[0].toLowerCase()) + ); + const lines = [ + "### 🙌 Contributors", + "", + `Thanks to everyone whose work landed in v${version}:`, + "", + "| Contributor | PRs / Issues |", + "| --- | --- |", + ]; + for (const [h, refs] of rows) { + lines.push(`| [@${h}](https://github.com/${h}) | ${fmt(refs)} |`); + } + lines.push(`| [@${MAINTAINER}](https://github.com/${MAINTAINER}) | ${maintainerNote} |`); + return lines.join("\n"); +} + +/** Insert or replace the Contributors section inside the version block, before its closing `---`. */ +export function injectContributors(changelog, version, table) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const startRe = new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"); + const sm = changelog.match(startRe); + if (!sm) return null; + const headerEnd = sm.index + sm[0].length; + const rest = changelog.slice(headerEnd); + const nextIdx = rest.search(/\n## \[/); + const bodyEnd = nextIdx === -1 ? changelog.length : headerEnd + nextIdx; + let body = changelog.slice(headerEnd, bodyEnd); + // strip an existing Contributors section (idempotent re-run) + body = body.replace(/\n### 🙌 Contributors[\s\S]*?(?=\n---\n|$)/, "\n"); + // insert before the trailing `---` (or append if none) + const idx = body.lastIndexOf("\n---"); + const insertion = `\n${table}\n`; + body = idx >= 0 ? body.slice(0, idx) + insertion + body.slice(idx) : `${body}${insertion}\n---\n`; + return changelog.slice(0, headerEnd) + body + changelog.slice(bodyEnd); +} + +function main(argv) { + const version = argv[0]; + const inject = argv.includes("--inject"); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + process.stderr.write("usage: gen-contributors.mjs [--inject]\n"); + process.exit(1); + } + const clPath = path.join(ROOT, "CHANGELOG.md"); + const changelog = fs.readFileSync(clPath, "utf8"); + const section = extractVersionSection(changelog, version); + if (section == null) { + process.stderr.write(`No [${version}] section in CHANGELOG.md\n`); + process.exit(2); + } + const agg = parseContributors(section); + const table = renderContributors(version, agg); + if (!inject) { + process.stdout.write(table + "\n"); + return; + } + const next = injectContributors(changelog, version, table); + if (next == null) { + process.stderr.write(`Could not locate [${version}] block for injection\n`); + process.exit(3); + } + fs.writeFileSync(clPath, next); + process.stderr.write(`✓ Injected ${agg.size} external contributor(s) into [${version}]\n`); +} + +// direct-run guard (importable for tests) +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release/list-uncovered-commits.mjs b/scripts/release/list-uncovered-commits.mjs new file mode 100644 index 0000000000..ba8127c124 --- /dev/null +++ b/scripts/release/list-uncovered-commits.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT +// represented in the current version's CHANGELOG section (or [Unreleased]). +// +// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so +// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had +// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at +// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens. +// +// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window +// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite +// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the +// maintainer can consolidate rather than write one bullet each. +// +// Usage: node scripts/release/list-uncovered-commits.mjs [--json] +// Exit: 0 always (advisory). Prints a report to stdout. + +import { execFileSync } from "node:child_process"; +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)), "..", ".."); +const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); + +const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]); + +export function refsOf(subject) { + return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); +} + +export function typeOf(subject) { + const m = subject.match(/^([a-z]+)(\(|:|!)/); + return m ? m[1] : "other"; +} + +/** + * @param {{hash:string, subject:string}[]} commits + * @param {Set} changelogRefs every #N present in the CHANGELOG scan window + * @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}} + */ +export function computeUncovered(commits, changelogRefs) { + const uncovered = []; + let covered = 0; + for (const c of commits) { + const refs = refsOf(c.subject); + const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r)); + if (isCovered) { + covered++; + } else { + const type = typeOf(c.subject); + uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) }); + } + } + return { covered, uncovered }; +} + +/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */ +export function changelogRefWindow(changelog, version) { + const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // From [Unreleased] up to (but excluding) the version-after-this one. + const startRe = /^## \[Unreleased\]/m; + const s = changelog.match(startRe); + const from = s ? s.index : 0; + // find the header AFTER the target version + const verRe = new RegExp(`^## \\[${esc}\\]`, "m"); + const vm = changelog.slice(from).match(verRe); + const afterVersionStart = vm ? from + vm.index + vm[0].length : from; + const rest = changelog.slice(afterVersionStart); + const nextIdx = rest.search(/\n## \[/); + const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx; + const window = changelog.slice(from, to); + return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1]))); +} + +function main(argv) { + const jsonOut = argv.includes("--json"); + const lastTag = git(["describe", "--tags", "--abbrev=0"]); + const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const log = git(["log", "--no-merges", `${lastTag}..HEAD`, "--pretty=format:%h%x09%s"]); + const commits = log + ? log.split("\n").map((l) => { + const [hash, subject] = l.split("\t"); + return { hash, subject }; + }) + : []; + const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8"); + const refs = changelogRefWindow(changelog, version); + const { covered, uncovered } = computeUncovered(commits, refs); + + if (jsonOut) { + process.stdout.write( + JSON.stringify({ version, lastTag, total: commits.length, covered, uncovered }, null, 2) + + "\n" + ); + return; + } + const bulletsWorthy = uncovered.filter((c) => !c.rollup); + const rollupCandidates = uncovered.filter((c) => c.rollup); + process.stdout.write(`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD)\n\n`); + process.stdout.write( + `Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n` + ); + process.stdout.write( + `## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n` + ); + for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n` + ); + for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`); + process.stdout.write( + `\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 017d14de00..37db2baa6b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -10,6 +10,7 @@ import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; @@ -161,13 +162,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Electron internal auto-updater state and listeners const [electronUpdateStatus, setElectronUpdateStatus] = useState<{ status: - | "idle" - | "checking" - | "available" - | "not-available" - | "downloading" - | "downloaded" - | "error"; + "idle" | "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error"; version?: string; percent?: number; message?: string; @@ -685,7 +680,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (contentType.includes("application/json")) { const data = await res.json(); if (!res.ok || !data.success) { - notify.error(data.error || "Failed to start update."); + // #5991: the error envelope is `{ error: { code, message, correlation_id } }`. + // Passing the raw object to notify.error() rendered it as a React child → + // "Minified React error #31" crash ("Internal Server Error" screen), e.g. on + // the 403 from the loopback-only /api/system/version. Extract the string. + notify.error(extractApiErrorMessage(data, "Failed to start update.")); setUpdating(false); setUpdatePhase("idle"); return; @@ -1109,7 +1108,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {

{t.rich("step1Desc", { endpoint: (chunks) => ( - + {chunks} ), diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 362196321d..3a54c5a74f 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -222,6 +222,7 @@ export default function ApiManagerPageClient() { const [isSubmitting, setIsSubmitting] = useState(false); const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); + const [deviceCounts, setDeviceCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); // Per-row API key visibility toggle (eye / eye-off). Keys default to masked. // Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal. @@ -367,6 +368,7 @@ export default function ApiManagerPageClient() { // Fetch usage stats after keys are loaded fetchUsageStats(data.keys || []); fetchSessionCounts(data.keys || []); + fetchDeviceCounts(data.keys || []); } } catch (error) { console.log("Error fetching keys:", error); @@ -447,6 +449,35 @@ export default function ApiManagerPageClient() { } }; + // Per-key device/connection counts (port of upstream 9router#931, thanks + // @mugnimaestra). One lightweight GET per key against + // /api/keys/[id]/devices — device counts are in-memory + TTL-evicted, so + // this is a much smaller payload than session data. + const fetchDeviceCounts = async (apiKeys: ApiKey[]) => { + if (apiKeys.length === 0) { + setDeviceCounts({}); + return; + } + try { + const results = await Promise.all( + apiKeys.map(async (key) => { + try { + const res = await fetch(`/api/keys/${encodeURIComponent(key.id)}/devices`); + if (!res.ok) return [key.id, 0] as const; + const data = await res.json(); + const count = typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; + return [key.id, count] as const; + } catch { + return [key.id, 0] as const; + } + }) + ); + setDeviceCounts(Object.fromEntries(results)); + } catch (error) { + console.log("Error fetching device counts:", error); + } + }; + const clearPageError = useCallback(() => setPageError(null), []); const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]); @@ -963,6 +994,7 @@ export default function ApiManagerPageClient() { const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; const activeSessions = sessionCounts[key.id] || 0; + const deviceCount = deviceCounts[key.id] || 0; const hasSchedule = key.accessSchedule?.enabled === true; const keyIsQuota = isQuotaKey(key); const groups = quotaGroupsForKey(key); @@ -1124,6 +1156,15 @@ export default function ApiManagerPageClient() { Sessions: {activeSessions}/{maxSessions} )} + {deviceCount > 0 && ( + + devices + {t("devicesCount", { count: deviceCount })} + + )} {hasThrottle && ( speed+ diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx new file mode 100644 index 0000000000..e98b7a56fd --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +type CompatMode = "off" | "auto" | "always"; + +const MODES: CompatMode[] = ["off", "auto", "always"]; + +const MODE_STYLES: Record = { + off: "bg-black/5 dark:bg-white/5 text-text-muted border-border", + auto: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/40", + always: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/40", +}; + +function isCompatMode(value: unknown): value is CompatMode { + return value === "off" || value === "auto" || value === "always"; +} + +/** + * Opt-in toggle (default "off") for Claude Code's auto-permission classifier compat mode. + * + * Claude Code's `--permission-mode auto` sends an internal `/v1/messages` security-classifier + * request that requires the response to START with `no` (ALLOW) / `yes` + * (BLOCK). When a combo/fallback route sends that call to a cheap model returning empty content, + * Claude Code fails closed on every gated action. "auto" detects the classifier request and + * short-circuits with a synthetic ALLOW response without calling upstream; "always" applies it to + * every Claude-format request. Cycles off → auto → always via the existing /api/settings PATCH. + */ +export default function ClaudeClassifierCompatToggle() { + const [mode, setMode] = useState("off"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/settings"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setMode(isCompatMode(data?.claudeClassifierCompat) ? data.claudeClassifierCompat : "off"); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load setting"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const cycle = useCallback(async () => { + const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length]; + const previous = mode; + setMode(next); // optimistic + setSaving(true); + setError(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ claudeClassifierCompat: next }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + setMode(previous); // revert on failure + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + }, [mode]); + + return ( +

+
+
+

Auto-permission classifier compat

+

+ Short-circuit Claude Code's --permission-mode auto security classifier + with a synthetic allow, so fallback routes don't fail closed. Off by default. +

+
+ +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx index 832ff024b2..66871aaceb 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import CliStatusBadge from "./CliStatusBadge"; +import ClaudeClassifierCompatToggle from "./ClaudeClassifierCompatToggle"; import { useTranslations } from "next-intl"; import { getStoredClaudeAuthValue, @@ -492,6 +493,9 @@ export default function ClaudeToolCard({ ))} + {/* Opt-in (default off): Claude Code auto-permission classifier compat mode. */} + + {message && (
; + +type BadgeVariant = "default" | "success" | "warning" | "error"; + +const RISK_VARIANT: Record = { + none: "success", + low: "success", + medium: "warning", + high: "error", + critical: "error", +}; + +const STATUS_VARIANT: Record = { + verified: "success", + testing: "warning", + pending: "default", + rejected: "error", +}; + +/** Run a fetch, surface a localized error via `setFeedback`, and report success. */ +async function callApi( + fn: () => Promise, + t: Translate, + failKey: string, + setFeedback: (f: Feedback) => void +): Promise { + setFeedback(null); + try { + const res = await fn(); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || t(failKey)); + return true; + } catch (err) { + setFeedback({ type: "error", message: err instanceof Error ? err.message : t(failKey) }); + return false; + } +} + +/** Results list + loading + feedback state, plus the reload function. */ +function useDiscoveryResults(t: Translate) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [feedback, setFeedback] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/discovery/results"); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || t("loadFailed")); + setResults(Array.isArray(data.results) ? data.results : []); + } catch (err) { + setFeedback({ type: "error", message: err instanceof Error ? err.message : t("loadFailed") }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + return { results, loading, feedback, setFeedback, load }; +} + +/** Scan / verify / delete actions and their transient state. */ +function useDiscoveryActions( + t: Translate, + load: () => Promise, + setFeedback: (f: Feedback) => void +) { + const [scanTarget, setScanTarget] = useState(""); + const [scanning, setScanning] = useState(false); + const [busyId, setBusyId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const scan = useCallback(async () => { + const providerId = scanTarget.trim(); + if (!providerId) return; + setScanning(true); + const ok = await callApi( + () => + fetch("/api/discovery/scan", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerId }), + }), + t, + "scanFailed", + setFeedback + ); + setScanning(false); + if (ok) { + setScanTarget(""); + setFeedback({ type: "success", message: t("scanQueued", { provider: providerId }) }); + await load(); + } + }, [scanTarget, t, load, setFeedback]); + + const verify = useCallback( + async (row: DiscoveryResult) => { + setBusyId(row.id); + const ok = await callApi( + () => fetch(`/api/discovery/verify/${row.id}`, { method: "POST" }), + t, + "verifyFailed", + setFeedback + ); + setBusyId(null); + if (ok) await load(); + }, + [t, load, setFeedback] + ); + + const remove = useCallback(async () => { + if (!deleteTarget) return; + setBusyId(deleteTarget.id); + const ok = await callApi( + () => fetch(`/api/discovery/results/${deleteTarget.id}`, { method: "DELETE" }), + t, + "deleteFailed", + setFeedback + ); + setBusyId(null); + if (ok) { + setDeleteTarget(null); + await load(); + } + }, [deleteTarget, t, load, setFeedback]); + + return { + scanTarget, + setScanTarget, + scanning, + busyId, + deleteTarget, + setDeleteTarget, + scan, + verify, + remove, + }; +} + +/** State + data orchestration for the discovery page, kept out of the view. */ +function useDiscovery() { + const t = useTranslations("discovery"); + const { results, loading, feedback, setFeedback, load } = useDiscoveryResults(t); + const actions = useDiscoveryActions(t, load, setFeedback); + return { t, results, loading, feedback, ...actions }; +} + +function DiscoveryScanForm({ + t, + value, + onChange, + onScan, + scanning, +}: { + t: Translate; + value: string; + onChange: (v: string) => void; + onScan: () => void; + scanning: boolean; +}) { + return ( + +
+
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onScan(); + }} + /> +
+ +
+

{t("localOnlyNote")}

+
+ ); +} + +function DiscoveryResultCard({ + t, + row, + busy, + onVerify, + onDelete, +}: { + t: Translate; + row: DiscoveryResult; + busy: boolean; + onVerify: () => void; + onDelete: () => void; +}) { + return ( + +
+
+
+ {row.providerId} + {row.status} + + {t("risk")}: {row.riskLevel} + +
+
+ {t("method")}: {row.method} · {t("auth")}: {row.authType} · {t("feasibility")}:{" "} + {row.feasibility}/5 +
+ {row.endpoint && ( +
{row.endpoint}
+ )} + {row.models && row.models.length > 0 && ( +
+ {t("models")}: {row.models.join(", ")} +
+ )} +
+
+ {row.status !== "verified" && ( + + )} + +
+
+
+ ); +} + +export function DiscoveryPageClient() { + const d = useDiscovery(); + + return ( +
+
+

{d.t("title")}

+

{d.t("subtitle")}

+
+ + void d.scan()} + scanning={d.scanning} + /> + + {d.feedback && ( +
+ {d.feedback.message} +
+ )} + + {d.loading ? ( +
+ +
+ ) : d.results.length === 0 ? ( + + ) : ( +
    + {d.results.map((row) => ( +
  • + void d.verify(row)} + onDelete={() => d.setDeleteTarget(row)} + /> +
  • + ))} +
+ )} + + void d.remove()} + onClose={() => d.setDeleteTarget(null)} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx b/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx new file mode 100644 index 0000000000..e5b46427e7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { DiscoveryPageClient } from "../DiscoveryPageClient"; + +// Stable `t` reference: useTranslations must return the SAME function across +// renders, otherwise the `load` useCallback (dep [t]) changes every render and +// the fetch-on-mount useEffect loops forever. +const t = (key: string, vars?: Record) => + vars ? `${key}:${JSON.stringify(vars)}` : key; + +vi.mock("next-intl", () => ({ + useTranslations: () => t, +})); + +function mockFetchOnce(results: unknown[]) { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ results }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("DiscoveryPageClient", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("loads and renders discovery results from the API", async () => { + mockFetchOnce([ + { + id: 1, + providerId: "huggingchat", + method: "free_tier", + authType: "none", + feasibility: 5, + riskLevel: "none", + status: "verified", + models: ["mixtral"], + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("huggingchat")).toBeInTheDocument(); + }); + // status + risk badges render (mocked t returns the key) + expect(screen.getByText("verified")).toBeInTheDocument(); + }); + + it("shows the empty state when there are no results", async () => { + mockFetchOnce([]); + + render(); + + await waitFor(() => { + expect(screen.getByText("emptyTitle")).toBeInTheDocument(); + }); + }); + + it("calls the discovery results endpoint on mount", async () => { + const fetchMock = mockFetchOnce([]); + render(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/discovery/results"); + }); + }); +}); diff --git a/src/app/(dashboard)/dashboard/discovery/page.tsx b/src/app/(dashboard)/dashboard/discovery/page.tsx new file mode 100644 index 0000000000..a89031e19d --- /dev/null +++ b/src/app/(dashboard)/dashboard/discovery/page.tsx @@ -0,0 +1,5 @@ +import { DiscoveryPageClient } from "./DiscoveryPageClient"; + +export default function DiscoveryPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx index 9ca2963b66..c1eed94d7c 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx @@ -14,6 +14,7 @@ import { WebSearchExampleCard } from "../../components/WebSearchExampleCard"; import { WebFetchExampleCard } from "../../components/WebFetchExampleCard"; import { VideoExampleCard } from "../../components/VideoExampleCard"; import { MusicExampleCard } from "../../components/MusicExampleCard"; +import { OcrExampleCard } from "../../components/OcrExampleCard"; interface Connection { id: string; @@ -53,6 +54,8 @@ function renderPlayground(kind: MediaKind, providerId: string) { return ; case "music": return ; + case "ocr": + return ; case "imageToText": // Endpoint /api/v1/images/understanding does not exist yet — omitted. return ( diff --git a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx index 2df7e2a28b..fea18d1207 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx @@ -1,12 +1,54 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { useApiKey } from "../../providers/hooks/useApiKey"; import { useProviderModels } from "../../providers/hooks/useProviderModels"; import { buildCurl } from "../../providers/utils/buildCurl"; import { PlaygroundCard } from "./PlaygroundCard"; +interface SuggestedHfModel { + id: string; + likes?: number; + downloads?: number; +} + +/** + * useHfSuggestedImageModels — fetch suggested HuggingFace Hub image models + * via GET /api/v1/providers/suggested-models?type=image. Only meaningful for + * the `huggingface` provider (the only image-kind entry backed by HF Hub); + * other providers simply never trigger the fetch. + */ +function useHfSuggestedImageModels(providerId: string): SuggestedHfModel[] { + // Keep the fetched models tagged with the providerId they were fetched + // for, and derive the return value below — this avoids ever calling + // setState synchronously from the effect body (react-hooks/set-state-in-effect) + // for the "not huggingface" early-return case; switching providers simply + // stops matching the tag instead of requiring an explicit reset call. + const [fetched, setFetched] = useState<{ providerId: string; models: SuggestedHfModel[] } | null>( + null + ); + + useEffect(() => { + if (providerId !== "huggingface") return; + let cancelled = false; + fetch("/api/v1/providers/suggested-models?type=image") + .then((res) => (res.ok ? (res.json() as Promise<{ data?: SuggestedHfModel[] }>) : null)) + .then((data) => { + if (cancelled || !data) return; + setFetched({ providerId, models: Array.isArray(data.data) ? data.data : [] }); + }) + .catch(() => { + // Best-effort suggestions — the static model list still works. + }); + return () => { + cancelled = true; + }; + }, [providerId]); + + return fetched && fetched.providerId === providerId ? fetched.models : []; +} + interface Props { providerId: string; } @@ -54,8 +96,10 @@ function ImageResultRenderer(data: unknown) { export function ImageExampleCard({ providerId }: Props) { const t = useTranslations("miniPlayground"); + const tMedia = useTranslations("media"); const { apiKey } = useApiKey(); const { models } = useProviderModels(providerId); + const suggestedModels = useHfSuggestedImageModels(providerId); const firstModel = models[0]?.id ?? "dall-e-3"; const [model, setModel] = useState(""); @@ -109,7 +153,10 @@ export function ImageExampleCard({ providerId }: Props) { } }; - const modelOptions = models.length > 0 ? models : [{ id: "dall-e-3" }]; + const staticModelOptions = models.length > 0 ? models : [{ id: "dall-e-3" }]; + const knownModelIds = new Set(staticModelOptions.map((m) => m.id)); + const suggestedOnly = suggestedModels.filter((m) => !knownModelIds.has(m.id)); + const modelOptions = [...staticModelOptions, ...suggestedOnly.map((m) => ({ id: m.id }))]; return (
+ {/* Suggested models from HuggingFace Hub (image kind only) */} + {suggestedOnly.length > 0 && ( +
+ +
+ {suggestedOnly.map((m) => ( + + ))} +
+
+ )} {/* Size */}
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx new file mode 100644 index 0000000000..f5a675b034 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useApiKey } from "../../providers/hooks/useApiKey"; +import { useProviderModels } from "../../providers/hooks/useProviderModels"; +import { buildCurl } from "../../providers/utils/buildCurl"; +import { PlaygroundCard } from "./PlaygroundCard"; + +interface Props { + providerId: string; +} + +const ENDPOINT_PATH = "/api/v1/ocr"; +const SAMPLE_DOCUMENT_URL = "https://arxiv.org/pdf/2201.04234"; + +function extractError(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const d = data as Record; + const err = d.error as Record | undefined; + if (err?.message) return String(err.message); + if (typeof d.message === "string") return d.message; + return null; +} + +export function OcrExampleCard({ providerId }: Props) { + const t = useTranslations("miniPlayground"); + const { apiKey } = useApiKey(); + const { models } = useProviderModels(providerId); + + const firstModel = models[0]?.id ?? ""; + const [model, setModel] = useState(""); + const [documentUrl, setDocumentUrl] = useState(SAMPLE_DOCUMENT_URL); + const [running, setRunning] = useState(false); + const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>(); + const [error, setError] = useState(null); + + const effectiveModel = model || firstModel; + + const buildBody = () => ({ + model: effectiveModel, + document: { type: "document_url", document_url: documentUrl }, + }); + + const curlSnippet = buildCurl({ + endpoint: + (typeof window !== "undefined" ? window.location.origin : "http://localhost:20128") + + ENDPOINT_PATH, + headers: { + Authorization: `Bearer ${apiKey || ""}`, + "Content-Type": "application/json", + }, + body: buildBody(), + }); + + const handleRun = async () => { + setRunning(true); + setError(null); + setResult(undefined); + const t0 = performance.now(); + try { + const res = await fetch(ENDPOINT_PATH, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "x-connection-id": providerId, + }, + body: JSON.stringify(buildBody()), + }); + const data: unknown = await res.json(); + const latencyMs = performance.now() - t0; + const errMsg = extractError(data); + if (!res.ok || errMsg) { + setError(errMsg ?? `HTTP ${res.status}`); + } else { + setResult({ data, latencyMs }); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setRunning(false); + } + }; + + const modelOptions = models.length > 0 ? models : [{ id: "mistral-ocr-latest" }]; + + return ( + + {/* Model select */} +
+ + +
+ {/* Document URL */} +
+ + setDocumentUrl(e.target.value)} + placeholder={SAMPLE_DOCUMENT_URL} + className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx b/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx index bcd50300e4..2e54dd4841 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs.tsx @@ -15,6 +15,7 @@ const KIND_ICON: Record = { webFetch: "language", video: "videocam", music: "music_note", + ocr: "document_scanner", }; interface ServiceKindTabsProps { diff --git a/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts b/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts index a3ded4173e..05b5586e34 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts +++ b/src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts @@ -7,7 +7,8 @@ export type MediaKind = | "webSearch" | "webFetch" | "video" - | "music"; + | "music" + | "ocr"; export const MEDIA_KINDS: MediaKind[] = [ "embedding", @@ -19,4 +20,5 @@ export const MEDIA_KINDS: MediaKind[] = [ "webFetch", "video", "music", + "ocr", ]; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx index 1b01f2eb7d..5347c075a8 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx @@ -3,6 +3,7 @@ // Phase 1t.2 extraction — Issue #3501 import { useRouter } from "next/navigation"; import { Card, Button } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import { getApiLabel, getApiPath } from "../providerPageHelpers"; import type { ProviderMessageTranslator } from "../providerPageHelpers"; @@ -11,6 +12,8 @@ interface ProviderNode { apiType?: string; chatPath?: string; prefix?: string; + /** Optional operator-supplied remote icon URL (#2166). */ + iconUrl?: string; [key: string]: unknown; } @@ -42,24 +45,35 @@ export default function CompatibleNodeCard({ return (
-
-

- {isCcCompatible - ? t("ccCompatibleDetailsTitle") - : isAnthropicCompatible - ? t("anthropicCompatibleDetails") - : t("openaiCompatibleDetails")} -

-

- {getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "} - {(providerNode.baseUrl || "").replace(/\/$/, "")}/ - {getApiPath( - isCcCompatible, - isAnthropicCompatible, - providerNode?.apiType, - providerNode?.chatPath - )} -

+
+ {providerNode.iconUrl && ( + + )} +
+

+ {isCcCompatible + ? t("ccCompatibleDetailsTitle") + : isAnthropicCompatible + ? t("anthropicCompatibleDetails") + : t("openaiCompatibleDetails")} +

+

+ {getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "} + {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {getApiPath( + isCcCompatible, + isAnthropicCompatible, + providerNode?.apiType, + providerNode?.chatPath + )} +

+
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 049797e4df..ec0f1fcdf7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -11,6 +11,7 @@ interface EditCompatibleNodeModalNode { baseUrl?: string; chatPath?: string; modelsPath?: string; + iconUrl?: string; } interface EditCompatibleNodeModalProps { @@ -38,6 +39,7 @@ export default function EditCompatibleNodeModal({ baseUrl: "https://api.openai.com/v1", chatPath: "", modelsPath: "", + iconUrl: "", }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); @@ -63,6 +65,7 @@ export default function EditCompatibleNodeModal({ : "https://api.openai.com/v1"), chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), modelsPath: isCcCompatible ? "" : node.modelsPath || "", + iconUrl: node.iconUrl || "", }); setShowAdvanced( !!( @@ -93,6 +96,7 @@ export default function EditCompatibleNodeModal({ baseUrl: formData.baseUrl, chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), modelsPath: isCcCompatible ? "" : formData.modelsPath, + iconUrl: formData.iconUrl.trim(), }; if (!isAnthropic) { payload.apiType = formData.apiType; @@ -208,6 +212,13 @@ export default function EditCompatibleNodeModal({ }) } /> + setFormData({ ...formData, iconUrl: e.target.value })} + placeholder="https://example.com/logo.png" + hint={t("iconUrlHint")} + />