diff --git a/.env.example b/.env.example index 300ac6e517..0af00dcd54 100644 --- a/.env.example +++ b/.env.example @@ -690,7 +690,15 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.146 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)" + +# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths +# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — +# third-party-harness tool names are aliased to +# Claude Code canonical or PascalCase forms so Anthropic does not refuse the +# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to +# forward the original names verbatim (debugging only). +# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" @@ -919,10 +927,11 @@ APP_LOG_TO_FILE=true # 17. MEMORY OPTIMIZATION (Low-RAM / Docker) # ═══════════════════════════════════════════════════════════════════════════════ -# Node.js V8 heap limit in MB. -# Used by: Docker entrypoint — sets --max-old-space-size. -# Default: 256 (Docker) | system default (npm) -# OMNIROUTE_MEMORY_MB=256 +# Node.js V8 heap limit in MB, passed to the server via --max-old-space-size. +# Used by the standalone launcher (Docker CMD) and `omniroute serve`. +# Default: 512. Clamped to [64, 16384]. Raise it (e.g. 1024) if you see random +# OOM crashes under load or with a large SQLite DB (#2939). +# OMNIROUTE_MEMORY_MB=512 # ── CLI helpers (bin/cli/) ── # Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2ccff7c3..243efa7728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- **SessionPool — modular & provider-agnostic** (`open-sse/services/sessionPool/`) — pooled + cookie/session manager with round-robin fingerprint rotation (distinct fingerprint per pooled + session), per-session cooldown/backoff, and a provider-agnostic `webExecutorWrapper`. Adds pool + support for DuckDuckGo Web and LLM7 providers and an MCP `poolTools` toolset. (#2954 — thanks @oyi77) - **AgentBridge** (`/dashboard/tools/agent-bridge`) — MITM proxy consolidating 9 IDE agents (Antigravity, Kiro, GitHub Copilot, OpenAI Codex, Cursor IDE, Zed Industries, Claude Code, Open Code, Trae stub) with server card, per-agent setup wizard, model mapping table, @@ -42,6 +46,10 @@ - **Documentation** — `docs/frameworks/AGENTBRIDGE.md` and `docs/frameworks/TRAFFIC_INSPECTOR.md`; `docs/architecture/REPOSITORY_MAP.md` updated; `docs/reference/openapi.yaml` updated with ~28 new routes and 20+ new schemas. +- **i18n:** translate Ukrainian (uk-UA) menu and UI strings (#2981 — thanks @Lion-killer) +- **providers:** add SiliconFlow endpoint selector (#2975 — thanks @xz-dev) +- **oauth:** add Trae SOLO provider (work/code modes) (#2964 — thanks @S0yora) +- **providers:** add Qwen Web (chat.qwen.ai) web-cookie provider (#2947 — thanks @oyi77) ### Changed @@ -53,6 +61,136 @@ `INSPECTOR_TLS_INTERCEPT`, `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES`, `INSPECTOR_MAX_BODY_KB`, `INSPECTOR_MASK_SECRETS`, `INSPECTOR_LLM_HOSTS_EXTRA`, `INSPECTOR_INTERNAL_INGEST_TOKEN`). +### Fixed + +- **payload-rules:** saved payload rules now survive a server restart. When no + in-memory override is set (fresh process before the boot hook ran, or a + separate module instance in the standalone build), `getPayloadRulesConfig` + now reads the DB-persisted rules (the source of truth) before the file config, + instead of silently returning the empty file default. (#2986) +- **models/custom:** custom models can now carry a per-model `targetFormat` + override (e.g. an opencode-go custom model that must use the Anthropic Messages + shape). Previously custom models always routed as OpenAI-compatible because + `targetFormat` was neither persisted nor consulted at routing time. Threaded + through `addCustomModel`/`replaceCustomModels`/`updateCustomModel`, the API + schema/route, `getModelInfo`, and chatCore's targetFormat resolution. (#2905) +- **providers/pollinations:** route to `gen.pollinations.ai/v1` instead of the + retired `text.pollinations.ai` host, which now returns `404 "legacy API"` for + all models. The gen gateway is the current OpenAI-compatible endpoint. (#2987) +- **executors/codex:** drop the CLI-injected `image_generation` hosted tool for + free-plan Codex accounts (`workspacePlanType === "free"`), which can't run it + server-side and would otherwise get an upstream 400. Paid plans keep it. + (mirrors CLIProxyAPI's free-plan guard; spun off from the #2980 analysis) +- **dashboard:** custom providers (`openai-compatible-*` / `anthropic-compatible-*`) + now show their user-given node name instead of the raw UUID id across the + active-requests panel, proxy logger, and home-page provider topology. The + display-label resolver was extracted into a shared util reused by all surfaces + (previously only the request-log viewer resolved it). (#2968) +- **docker:** the standalone launcher (Docker `CMD`) now honors + `OMNIROUTE_MEMORY_MB` (default 512, clamped [64, 16384]) and overrides the + image `NODE_OPTIONS` fallback, fixing random OOM crashes under load / with + large SQLite DBs. Previously only `omniroute serve` honored the knob. (#2939) +- **docker:** add a `web` compose profile (`omniroute-web`, target `runner-web`, + image `omniroute:web`) so web-cookie providers (gemini-web, claude-web, + claude-turnstile) work out of the box — the default `base` image ships without + Chromium/Playwright, which made those providers fail with + "Executable doesn't exist at .../ms-playwright/chromium...". (#2832) +- **routing/codex:** fix two gpt-5.5 Codex defects (#2877). (A) For a Codex-only + account, a bare `gpt-5.5` Responses request was rerouted to codex with the + model hardcoded to `gpt-5.5-medium` (`chatHelpers.ts`); the executor read that + `-medium` suffix as an explicit `modelEffort` that (per #2331) overrode a + client `reasoning.effort=xhigh`, silently demoting it — now it keeps the bare + `gpt-5.5` id so the client effort wins. (B) `gpt-5.5-xhigh`/`-high`/`-low` + misrouted to `openai` (→ "No credentials" for codex-only users); the suffixed + variants are now in `CODEX_PREFERRED_UNPREFIXED_MODELS` so they infer codex. +- **sse/chatCore:** remove a duplicate `const settings` declaration in + `handleChatCore` (introduced alongside the per-key stream-default-mode + feature). The same-scope redeclaration made esbuild/tsx fail with + "The symbol 'settings' has already been declared", which turned every unit + test that imports chatCore red and broke the production build. The earlier + consolidated `settings` const is now reused. +- **db/migrations:** resolve a `077` migration version collision + (`077_api_key_stream_default_mode.sql` vs `077_quota_pools.sql`) that made + `getMigrationFiles()` throw and blocked `getDbInstance()` at startup (app would + not boot; every DB-touching test was red). Renumbered the dependency-free, + idempotent `quota_pools` migration to `085`, kept the non-idempotent + `api_key_stream_default_mode` `ALTER` at `077`, added a retroactive + `isSchemaAlreadyApplied` guard (case `085`), and a regression test enforcing + unique migration prefixes. +- **routing/reasoning-replay:** OpenCode `big-pickle` (provider `opencode`/`oc` + and `opencode-zen`) now declares the interleaved `reasoning_content` contract + via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns + replay reasoning_content. Previously `big-pickle` matched no replay pattern and + failed with `[400] The reasoning_content in the thinking mode must be passed + back to the API` (its DeepSeek-thinking upstream is not detectable from the + model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). + `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) +- **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini + models (`claude-opus-4.7`, `claude-opus-4-5-20251101`, `gemini-3.1-pro-preview`, + `gemini-3-flash-preview`) no longer carry `targetFormat: "openai-responses"`, so + they route through `chat/completions` (the provider default, like the working + `claude-opus-4.6`) instead of the Responses API, which Copilot does not serve for + non-OpenAI models (returned `[400]`). Native OpenAI `gpt-*` models keep the + Responses API. (#2911) +- **translator/responses:** Codex Desktop injects an `image_generation` hosted + tool into every Responses API request (even text-only ones), which OmniRoute + rejected with `[400] image_generation tool type is not supported`. It is now + treated like `tool_search`: allowed past the tool-type validator and dropped + silently from the tools array before forwarding to Chat Completions. (#2950) +- **combo/builder:** no-auth OpenCode Free combo entries now use the `oc/` routing + alias instead of the `opencode/` prefix. `parseModel("opencode/")` + resolves to the `opencode-zen` api-key tier (via a manual `ALIAS_TO_PROVIDER_ID` + override), so combos built with the bare provider id misrouted away from the + no-auth `opencode` provider; `oc/` resolves correctly. (#2901) +- **resilience/providers:** a route-restriction `403` (e.g. Fireworks Fire Pass + `fpk_*` keys returning "…not authorized for this route." on `/models`, while + chat still works) no longer marks the connection unavailable. Provider + validation falls through to the chat probe for such 403s instead of returning + "Invalid API key", and `checkFallbackError` short-circuits them to no cooldown. + Genuine auth failures (401 / generic 403) still fail fast. (#2929) +- **auth/opencode-zen:** the OpenCode Zen free model now works in the Playground + and combos without an API key. `opencode-zen` serves the public, signup-free + endpoint (`https://opencode.ai/zen/v1`); when no api-key connection is + configured, credential resolution now falls back to anonymous (no-auth) access + instead of failing with "No credentials for provider: opencode-zen". A + configured, active key is still used when present. (#2962) +- **translator/responses:** fixed an upstream `[400] Messages with role 'tool' + must be a response to a preceding message with 'tool_calls'` when a Codex + client sent a `function_call` with an empty/missing `call_id`. The orphaned + `function_call_output` previously slipped past the orphan filter. Now + empty-`call_id` function calls are skipped (no dangling assistant tool_call) + and any tool result without a matching tool_call id is dropped. (#2893) +- **oom:** resolve memory leak in Bottleneck limiter caches and provider registry (#2965 — thanks @soyelmismo) +- **proxy:** show registry provider proxies in dashboard after Custom proxy flow moved them into the proxy registry (#2963 — thanks @terence71-glitch) +- **routing:** add agy to executor map so it uses AntigravityExecutor (#2957 — thanks @ReqX) +- **skills:** avoid Claude assistant tool_result blocks (#2956 — thanks @terence71-glitch) +- **perf:** CPU leak from Bottleneck limiter accumulation + per-request optimizations (#2951 — thanks @soyelmismo) +- **combo:** combo credential resolution ignores target.providerId — prefer combo target's providerId over model-inferred provider (#2946 — thanks @oyi77) +- **dashboard:** v3.8.8 screen fixes — agent-bridge SSR + audit/logs/memory/playground (#2944) +- **claude:** sanitize tool schemas + cloak third-party tool names on native Claude OAuth (#2943 — thanks @NomenAK) +- **auth:** prevent Codex multi-account refresh_token family revocation (#2941) +- **combo:** fix combo vision passthrough and Codex tool history repair (#2940 — thanks @charithharshana) +- **claude:** map WebSearch to Responses web_search (#2938 — thanks @makcimbx) +- **claude:** strip empty Read pages tool input (#2937 — thanks @makcimbx) +- **dashboard:** improve self-service provider quota visibility (#2931 — thanks @guanbear) +- **antigravity:** avoid visible signatureless tool history (#2927 — thanks @dhaern) + +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + --- ## [3.8.7] — 2026-05-29 @@ -217,6 +355,8 @@ A special thanks to everyone who contributed to this release. Ranked by commits | [@levonk](https://github.com/levonk) | 1 | #2806 | _Reviews & additional contributions: @androw, @Ardem2025, @InkshadeWoods._ +A special thanks to everyone who contributed code, reviews, and tests for this release: +@akarray, @alltomatos, @androw, @apoapostolov, @Ardem2025, @dhaern, @disonjer, @gogones, @hartmark, @herjarsa, @InkshadeWoods, @jeferssonlemes, @leninejunior, @levonk, @marchlhw, @mugnimaestra, @nickwizard, @oyi77, @RajvardhanPatil07, @rdself, @soyelmismo, @Tushar49, @yunaamelia, Dmitry Kuznetsov, Nikolay Alafuzov --- diff --git a/Dockerfile b/Dockerfile index 3e3331a5df..135c6abceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,8 @@ LABEL org.opencontainers.image.title="omniroute" \ ENV NODE_ENV=production ENV PORT=20128 ENV HOSTNAME=0.0.0.0 -ENV NODE_OPTIONS="--max-old-space-size=256" +ENV OMNIROUTE_MEMORY_MB=1024 +ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}" # Data directory inside Docker — must match the volume mount in docker-compose.yml ENV DATA_DIR=/app/data diff --git a/docker-compose.yml b/docker-compose.yml index 0b5a762837..8a249e335f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,14 @@ # # Profiles: # base → minimal image, no CLI tools +# web → runner-web (+Chromium/Playwright) for web-cookie providers # cli → CLIs installed inside the container (portable) # host → runner-base + host-mounted CLI binaries (Linux-first) # cliproxyapi → CLIProxyAPI sidecar on port 8317 # # Usage: # docker compose --profile base up -d +# docker compose --profile web up -d # gemini-web / claude-web / claude-turnstile # docker compose --profile cli up -d # docker compose --profile host up -d # docker compose --profile cliproxyapi up -d @@ -69,6 +71,23 @@ services: profiles: - base + # ── Profile: web (runner-web + Chromium/Playwright) ──────────────── + # Required for web-cookie providers (gemini-web, claude-web, claude-turnstile). + # The default `base` image ships without Chromium, so those providers fail + # with "Executable doesn't exist at .../ms-playwright/chromium..." (#2832). + omniroute-web: + <<: *common + container_name: omniroute + build: + context: . + target: runner-web + image: omniroute:web + ports: + - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${API_PORT:-20129}:${API_PORT:-20129}" + profiles: + - web + # ── Profile: cli (CLIs installed inside container) ───────────────── omniroute-cli: <<: *common diff --git a/docs/bdd/self-service-api-key-usage.feature b/docs/bdd/self-service-api-key-usage.feature index 2b976c79fa..baad4c4faf 100644 --- a/docs/bdd/self-service-api-key-usage.feature +++ b/docs/bdd/self-service-api-key-usage.feature @@ -61,36 +61,51 @@ Feature: Self-service API key usage and account quota visibility Then the response status should be 200 And the response should not include shared account quota details - Scenario: Shared Codex account quota is visible with explicit permission + Scenario: Shared provider account quotas are visible with explicit permission + Given an API key named "team-a" has the scope "self:usage" + And "team-a" has the scope "self:account-quota" + And "team-a" is restricted to a Codex connection and a Claude connection + And Codex reports a session quota with 1 percent used + And Claude reports a daily quota with 35 percent used + When "team-a" calls GET "/api/v1/me/status" with its Bearer token + Then the response status should be 200 + And the response accountQuotas should contain 2 entries + And the first response accountQuotas entry provider should be "codex" + And the first response accountQuotas entry quotas.session.remainingPercentage should be 99 + And the second response accountQuotas entry provider should be "claude" + And the second response accountQuotas entry quotas.daily.remainingPercentage should be 65 + + Scenario: A single allowed provider also keeps the compatibility accountQuota field Given an API key named "team-a" has the scope "self:usage" And "team-a" has the scope "self:account-quota" And "team-a" is restricted to exactly one Codex connection - And Codex reports a session quota with 1 percent used And Codex reports a weekly quota with 97 percent used When "team-a" calls GET "/api/v1/me/status" with its Bearer token Then the response status should be 200 + And the response accountQuotas should contain 1 entry And the response accountQuota.provider should be "codex" - And the response accountQuota.shared should be true - And the response accountQuota.quotas.session.remainingPercentage should be 99 And the response accountQuota.quotas.weekly.remainingPercentage should be 3 - Scenario: Account quota is not guessed for multi-connection keys - Given an API key named "team-a" has the scope "self:usage" - And "team-a" has the scope "self:account-quota" - And "team-a" is allowed to use two provider connections - When "team-a" calls GET "/api/v1/me/status" with its Bearer token - Then the response status should be 200 - And the response accountQuota.available should be false - And the response accountQuota.reason should be "ambiguous_connection" - - Scenario: Account quota is not guessed for unrestricted connection keys + Scenario: Unrestricted keys can see all active provider account quotas Given an API key named "team-a" has the scope "self:usage" And "team-a" has the scope "self:account-quota" And "team-a" has no explicit allowed connection restrictions + And OmniRoute has active Codex and Cursor provider connections with quota data When "team-a" calls GET "/api/v1/me/status" with its Bearer token Then the response status should be 200 - And the response accountQuota.available should be false - And the response accountQuota.reason should be "ambiguous_connection" + And the response accountQuotas should contain the Codex account quota + And the response accountQuotas should contain the Cursor account quota + + Scenario: Provider connection lookup failures do not hide own usage + Given an API key named "team-a" has the scope "self:usage" + And "team-a" has the scope "self:account-quota" + And "team-a" is restricted to a Codex connection and another provider connection + And OmniRoute cannot resolve the other provider connection metadata + When "team-a" calls GET "/api/v1/me/status" with its Bearer token + Then the response status should be 200 + And the response should still include own cost and token usage + And the unresolved response accountQuotas entry should have available false + And the unresolved response accountQuotas entry reason should be "connection_lookup_failed" Scenario: Existing budget endpoint stays management-only Given an API key named "team-a" has the scope "self:usage" diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index ecc47fee8b..90ea99c5a6 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,18 +1,18 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.8 +lastUpdated: 2026-05-30 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations. +> Model Context Protocol server with 43 tools across routing, cache, compression, memory, skills, proxy, and context source operations. > -> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. +> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. -![MCP tool inventory (37 tools by category)](../diagrams/exported/mcp-tools-37.svg) +![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-43.svg) -> Source: [diagrams/mcp-tools-37.mmd](../diagrams/mcp-tools-37.mmd) +> Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (update from `mcp-tools-37` when regenerating) ## Installation @@ -158,22 +158,50 @@ the runtime compression model behind these tools. Defined in `open-sse/mcp-server/tools/memoryTools.ts`. Auth/scope is enforced through the standard MCP scope pipeline. -| Tool | Description | -| :------------------------ | :---------------------------------------------------------------------------------- | -| `omniroute_memory_search` | Search memories by query / type / API key with token-budget enforcement | -| `omniroute_memory_add` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | -| `omniroute_memory_clear` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | +| Tool | Scopes | Description | +| :------------------------ | :--------------- | :---------------------------------------------------------------------------------- | +| `omniroute_memory_search` | `read:memory` | Search memories by query / type / API key with token-budget enforcement | +| `omniroute_memory_add` | `write:memory` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | +| `omniroute_memory_clear` | `write:memory` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | ## Skill Tools (4) Defined in `open-sse/mcp-server/tools/skillTools.ts`. Backed by `src/lib/skills/registry` + `src/lib/skills/executor`. -| Tool | Description | -| :---------------------------- | :-------------------------------------------------------------------------------- | -| `omniroute_skills_list` | List registered skills with optional filtering by API key, name, or enabled state | -| `omniroute_skills_enable` | Enable or disable a specific skill by ID | -| `omniroute_skills_execute` | Execute a skill with provided input and return the execution record | -| `omniroute_skills_executions` | List recent skill execution history | +| Tool | Scopes | Description | +| :---------------------------- | :-------------- | :-------------------------------------------------------------------------------- | +| `omniroute_skills_list` | `read:skills` | List registered skills with optional filtering by API key, name, or enabled state | +| `omniroute_skills_enable` | `write:skills` | Enable or disable a specific skill by ID | +| `omniroute_skills_execute` | `execute:skills`| Execute a skill with provided input and return the execution record | +| `omniroute_skills_executions` | `read:skills` | List recent skill execution history | + +## Notion Context Source (6) + +Defined in `open-sse/mcp-server/tools/notionTools.ts`. Token stored in `key_value` table via `src/lib/db/notion.ts`. REST client in `src/lib/notion/api.ts`. Settings API in `src/app/api/settings/notion/route.ts`. Dashboard UI in `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx`. + +Configure your Notion integration token from the **Context Sources** tab in the Endpoint dashboard, or via the REST API: + +```bash +# Set token +curl -X POST http://localhost:20128/api/settings/notion \ + -H "Content-Type: application/json" \ + -d '{"token": "ntn_..."}' + +# Check status +curl http://localhost:20128/api/settings/notion + +# Disconnect +curl -X DELETE http://localhost:20128/api/settings/notion +``` + +| Tool | Scopes | Description | +| :--------------------------- | :--------------- | :------------------------------------------------------------------------------------ | +| `omniroute_notion_search` | `read:notion` | Full-text search across all pages and databases | +| `omniroute_notion_list_databases` | `read:notion` | List all accessible databases with schema metadata | +| `omniroute_notion_get_database` | `read:notion` | Get database schema by ID | +| `omniroute_notion_query_database` | `read:notion` | Query a database with filters, sorts, and pagination | +| `omniroute_notion_read` | `read:notion` | Read a page or block by ID with its content | +| `omniroute_notion_append_blocks` | `write:notion`| Append children blocks to a parent block (max 100 per request) | ## Agent Skill Catalog Tools (3) @@ -189,8 +217,8 @@ See [AGENT-SKILLS.md](./AGENT-SKILLS.md) for the full catalog and how external a ## Related Frameworks (v3.8.0) -The MCP tool inventory above (37 tools = 30 base + 3 memory + 4 skills) is intentionally -scoped to runtime routing/cache/compression/memory/skills/proxy operations. Two adjacent +The MCP tool inventory above (43 tools = 30 core + 3 memory + 4 skills + 6 notion) is intentionally +scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: ### Cloud Agents @@ -259,13 +287,17 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central | `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:compression` | `compression_configure`, `set_compression_engine` | | `read:proxies` | `oneproxy_fetch`, `oneproxy_rotate`, `oneproxy_stats` | - +| `read:notion` | `notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read` | +| `write:notion` | `notion_append_blocks` | +| `read:memory` | `memory_search` | +| `write:memory` | `memory_add`, `memory_clear` | +| `read:skills` | `skills_list`, `skills_executions` | +| `write:skills` | `skills_enable` | +| `execute:skills` | `skills_execute` | | `read:catalog` | `agent_skills_list`, `agent_skills_get`, `agent_skills_coverage` | Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access. -Memory and Skill tools currently do not declare static scope requirements in their definitions; access is gated by the caller's API key and audited through the standard MCP audit pipeline. Agent Skill Catalog tools require `read:catalog`. - --- ## Environment Variables @@ -308,7 +340,7 @@ The heartbeat snapshot contains: "transport": "stdio", "scopesEnforced": false, "allowedScopes": [], - "toolCount": 37 + "toolCount": 43 } ``` @@ -342,9 +374,19 @@ Use the dashboard or the `/api/mcp/audit` and `/api/mcp/audit/stats` REST endpoi | `open-sse/mcp-server/tools/compressionTools.ts` | Compression tool handlers | | `open-sse/mcp-server/tools/memoryTools.ts` | Memory tool definitions (3 tools) | | `open-sse/mcp-server/tools/skillTools.ts` | Skill tool definitions (4 tools) | +| `open-sse/mcp-server/tools/notionTools.ts` | Notion context source tool definitions (6 tools) | +| `open-sse/mcp-server/tools/gamificationTools.ts`| Gamification tool definitions (8 tools) | +| `open-sse/mcp-server/tools/pluginTools.ts` | Plugin registration and management tools (8 tools) | | `src/app/api/mcp/status/route.ts` | `/api/mcp/status` endpoint | | `src/app/api/mcp/tools/route.ts` | `/api/mcp/tools` endpoint | | `src/app/api/mcp/sse/route.ts` | `/api/mcp/sse` SSE transport route | | `src/app/api/mcp/stream/route.ts` | `/api/mcp/stream` Streamable HTTP transport route | | `src/app/api/mcp/audit/route.ts` | `/api/mcp/audit` audit log query | | `src/app/api/mcp/audit/stats/route.ts` | `/api/mcp/audit/stats` aggregated audit metrics | +| `src/lib/notion/api.ts` | Notion REST API client (retry, timeout, error classification) | +| `src/lib/db/notion.ts` | Notion token persistence (`key_value` table) | +| `src/app/api/settings/notion/route.ts` | Notion settings API (GET/POST/DELETE) | +| `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx` | Notion token management UI | +| `tests/unit/notion-api.test.ts` | Notion API client tests (7) | +| `tests/unit/notion-tools.test.ts` | Notion tools scope enforcement tests (10) | +| `tests/unit/db/notion.test.mjs` | Notion DB module tests (3) | diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index d76e976d81..f99a60075c 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -152,22 +152,29 @@ docker build --target runner-base -t omniroute:base . docker build --target runner-cli -t omniroute:cli . ``` -Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `NODE_OPTIONS=--max-old-space-size=256`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`. +Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `NODE_OPTIONS=--max-old-space-size=512`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`. + +Memory behavior in Docker: + +- `NODE_OPTIONS=--max-old-space-size=512` is baked into the image as a fallback. +- The actual server process is started by the standalone launcher, which reads `OMNIROUTE_MEMORY_MB` and appends `--max-old-space-size=`. +- Node uses the last repeated `--max-old-space-size` value, so setting `OMNIROUTE_MEMORY_MB` controls the effective Docker heap limit. +- If `OMNIROUTE_MEMORY_MB` is unset, the launcher uses `512`. ## Critical Environment Variables Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), the following variables matter most when running under Docker: -| Variable | Purpose | Default | -| ----------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------- | -| `OMNIROUTE_WS_BRIDGE_SECRET` | Shared secret for the WebSocket bridge. **Required in production** — set to a strong random string. | unset (must be provided) | -| `REDIS_URL` | Connection string for the rate limiter / cache backend | `redis://redis:6379` | -| `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` | -| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) | -| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) | -| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | -| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` | -| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` | +| Variable | Purpose | Default | +| ----------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------ | +| `OMNIROUTE_WS_BRIDGE_SECRET` | Shared secret for the WebSocket bridge. **Required in production** — set to a strong random string. | unset (must be provided) | +| `REDIS_URL` | Connection string for the rate limiter / cache backend | `redis://redis:6379` | +| `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` | +| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) | +| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image fallback above | `512` | +| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | +| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` | +| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` | ## Docker Compose with Caddy (HTTPS Auto-TLS) diff --git a/docs/i18n/ar/docs/reference/ENVIRONMENT.md b/docs/i18n/ar/docs/reference/ENVIRONMENT.md index 62d54a09bb..e25a4dd200 100644 --- a/docs/i18n/ar/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ar/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/az/docs/reference/ENVIRONMENT.md b/docs/i18n/az/docs/reference/ENVIRONMENT.md index ca7142edbf..2f97fa886a 100644 --- a/docs/i18n/az/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/az/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/bg/docs/reference/ENVIRONMENT.md b/docs/i18n/bg/docs/reference/ENVIRONMENT.md index d701c146d4..f8017787e5 100644 --- a/docs/i18n/bg/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/bg/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/bn/docs/reference/ENVIRONMENT.md b/docs/i18n/bn/docs/reference/ENVIRONMENT.md index fdaad38c00..30766263a2 100644 --- a/docs/i18n/bn/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/bn/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/cs/docs/reference/ENVIRONMENT.md b/docs/i18n/cs/docs/reference/ENVIRONMENT.md index 0a80489399..b897101e37 100644 --- a/docs/i18n/cs/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/cs/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/da/docs/reference/ENVIRONMENT.md b/docs/i18n/da/docs/reference/ENVIRONMENT.md index 659fd85724..df6b9ec659 100644 --- a/docs/i18n/da/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/da/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/de/docs/reference/ENVIRONMENT.md b/docs/i18n/de/docs/reference/ENVIRONMENT.md index a658cbae42..ebbdf2c488 100644 --- a/docs/i18n/de/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/de/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/es/docs/reference/ENVIRONMENT.md b/docs/i18n/es/docs/reference/ENVIRONMENT.md index ece267a70d..8136ec5645 100644 --- a/docs/i18n/es/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/es/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/fa/docs/reference/ENVIRONMENT.md b/docs/i18n/fa/docs/reference/ENVIRONMENT.md index 1053351fa1..01669eedde 100644 --- a/docs/i18n/fa/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fa/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/fi/docs/reference/ENVIRONMENT.md b/docs/i18n/fi/docs/reference/ENVIRONMENT.md index be6e03967a..861d3d2920 100644 --- a/docs/i18n/fi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fi/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/fr/docs/reference/ENVIRONMENT.md b/docs/i18n/fr/docs/reference/ENVIRONMENT.md index 7495c4237b..5e7071dbd1 100644 --- a/docs/i18n/fr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fr/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/gu/docs/reference/ENVIRONMENT.md b/docs/i18n/gu/docs/reference/ENVIRONMENT.md index a5ae7a464f..c6e12686f9 100644 --- a/docs/i18n/gu/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/gu/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/he/docs/reference/ENVIRONMENT.md b/docs/i18n/he/docs/reference/ENVIRONMENT.md index 1d8a6e0223..cc594b9f8f 100644 --- a/docs/i18n/he/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/he/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/hi/docs/reference/ENVIRONMENT.md b/docs/i18n/hi/docs/reference/ENVIRONMENT.md index 90b2c59df1..cc8a1f7fa1 100644 --- a/docs/i18n/hi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/hi/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/hu/docs/reference/ENVIRONMENT.md b/docs/i18n/hu/docs/reference/ENVIRONMENT.md index 01ae269eb6..93173817fb 100644 --- a/docs/i18n/hu/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/hu/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/id/docs/reference/ENVIRONMENT.md b/docs/i18n/id/docs/reference/ENVIRONMENT.md index e3dd9dfbd3..dec684ad6d 100644 --- a/docs/i18n/id/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/id/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/in/docs/reference/ENVIRONMENT.md b/docs/i18n/in/docs/reference/ENVIRONMENT.md index b0fdbc8716..80296ce1d1 100644 --- a/docs/i18n/in/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/in/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/it/docs/reference/ENVIRONMENT.md b/docs/i18n/it/docs/reference/ENVIRONMENT.md index 0c6b131611..d4138ee7b2 100644 --- a/docs/i18n/it/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/it/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ja/docs/reference/ENVIRONMENT.md b/docs/i18n/ja/docs/reference/ENVIRONMENT.md index d1036cbdb7..6b152d036e 100644 --- a/docs/i18n/ja/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ja/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ko/docs/reference/ENVIRONMENT.md b/docs/i18n/ko/docs/reference/ENVIRONMENT.md index d344f42bc7..c9c636f4d9 100644 --- a/docs/i18n/ko/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ko/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/mr/docs/reference/ENVIRONMENT.md b/docs/i18n/mr/docs/reference/ENVIRONMENT.md index 02ea9add89..81cf6afd1a 100644 --- a/docs/i18n/mr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/mr/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ms/docs/reference/ENVIRONMENT.md b/docs/i18n/ms/docs/reference/ENVIRONMENT.md index deb9760d96..77bf232cb5 100644 --- a/docs/i18n/ms/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ms/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/nl/docs/reference/ENVIRONMENT.md b/docs/i18n/nl/docs/reference/ENVIRONMENT.md index ff3fe7b97f..4a1fdfd53e 100644 --- a/docs/i18n/nl/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/nl/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/no/docs/reference/ENVIRONMENT.md b/docs/i18n/no/docs/reference/ENVIRONMENT.md index 6090094989..58d9942eac 100644 --- a/docs/i18n/no/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/no/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/phi/docs/reference/ENVIRONMENT.md b/docs/i18n/phi/docs/reference/ENVIRONMENT.md index 7083935190..3592c23485 100644 --- a/docs/i18n/phi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/phi/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/pl/docs/reference/ENVIRONMENT.md b/docs/i18n/pl/docs/reference/ENVIRONMENT.md index 6148bbb1b8..0c23ef265d 100644 --- a/docs/i18n/pl/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pl/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md b/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md index a3cfd48f60..c00c024987 100644 --- a/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/pt/docs/reference/ENVIRONMENT.md b/docs/i18n/pt/docs/reference/ENVIRONMENT.md index 08486a93bb..96e8faf309 100644 --- a/docs/i18n/pt/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pt/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ro/docs/reference/ENVIRONMENT.md b/docs/i18n/ro/docs/reference/ENVIRONMENT.md index aacd282981..429448d0b2 100644 --- a/docs/i18n/ro/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ro/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ru/docs/reference/ENVIRONMENT.md b/docs/i18n/ru/docs/reference/ENVIRONMENT.md index be76e294b0..ad6336a1b9 100644 --- a/docs/i18n/ru/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ru/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/sk/docs/reference/ENVIRONMENT.md b/docs/i18n/sk/docs/reference/ENVIRONMENT.md index b7d26d0c59..e8fcfc8c22 100644 --- a/docs/i18n/sk/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sk/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/sv/docs/reference/ENVIRONMENT.md b/docs/i18n/sv/docs/reference/ENVIRONMENT.md index 3339bf9b45..30a72885e1 100644 --- a/docs/i18n/sv/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sv/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/sw/docs/reference/ENVIRONMENT.md b/docs/i18n/sw/docs/reference/ENVIRONMENT.md index d4d29a96d5..3a6a61869e 100644 --- a/docs/i18n/sw/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sw/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ta/docs/reference/ENVIRONMENT.md b/docs/i18n/ta/docs/reference/ENVIRONMENT.md index 55eee06034..7b705a27f0 100644 --- a/docs/i18n/ta/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ta/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/te/docs/reference/ENVIRONMENT.md b/docs/i18n/te/docs/reference/ENVIRONMENT.md index f185e2d42d..810bc75744 100644 --- a/docs/i18n/te/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/te/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/th/docs/reference/ENVIRONMENT.md b/docs/i18n/th/docs/reference/ENVIRONMENT.md index fe5ba5bf30..4f0d67b714 100644 --- a/docs/i18n/th/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/th/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/tr/docs/reference/ENVIRONMENT.md b/docs/i18n/tr/docs/reference/ENVIRONMENT.md index df3fd9003b..db1ec488d3 100644 --- a/docs/i18n/tr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/tr/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md b/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md index a2243d09f4..bb4a868742 100644 --- a/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/ur/docs/reference/ENVIRONMENT.md b/docs/i18n/ur/docs/reference/ENVIRONMENT.md index 0e81174240..be4e43af97 100644 --- a/docs/i18n/ur/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ur/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/vi/docs/reference/ENVIRONMENT.md b/docs/i18n/vi/docs/reference/ENVIRONMENT.md index 22c5c67c48..b2666462db 100644 --- a/docs/i18n/vi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/vi/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 687d2ded78..5b8a95c788 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -487,7 +487,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/openspec/changes/self-service-api-key-usage/proposal.md b/docs/openspec/changes/self-service-api-key-usage/proposal.md index 1d8708d0ad..1c9a36e164 100644 --- a/docs/openspec/changes/self-service-api-key-usage/proposal.md +++ b/docs/openspec/changes/self-service-api-key-usage/proposal.md @@ -21,7 +21,7 @@ In scope: - New `GET /api/v1/me/status` endpoint authenticated by normal Bearer API key. - New self-service API key scopes: `self:usage` and `self:account-quota`. - Per-key cost and token aggregation for the calling key. -- Optional normalized provider account quota for unambiguous single-connection keys. +- Optional normalized provider account quotas for all provider-limit connections the key may use. - API Manager create/edit controls for visibility scopes. - Reuse the existing budget configuration surface for USD limits. - i18n message keys for all new dashboard text. @@ -46,7 +46,7 @@ The new scopes must not grant management access. Only `manage` and `admin` remai - Scope editing in the current dashboard can collapse scopes to only management access; implementation must preserve unrelated scopes. - Shared account quota can reveal account exhaustion; it must remain disabled by default. -- Multi-connection and unrestricted-connection keys are ambiguous; first implementation should decline account quota rather than guessing. +- Unrestricted keys can use all active provider connections, so account quota visibility enumerates all active provider-limit connections when explicitly permitted. - Backfill must be idempotent so upgrades do not repeatedly rewrite API keys or re-enable a permission an operator later disabled. - New UI text can regress non-English dashboards if translation keys are not added consistently. - The current scope validation cap is 16 entries; adding self-service scopes may require raising that cap. diff --git a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md b/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md index 2fe7620689..f704c08130 100644 --- a/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md +++ b/docs/openspec/changes/self-service-api-key-usage/specs/api-key-self-service-usage/spec.md @@ -119,30 +119,48 @@ The self-service endpoint SHALL include shared account quota only when the authe - WHEN it calls the self-service endpoint - THEN the response SHALL NOT include shared account quota details -#### Scenario: Codex quota shown with explicit permission +#### Scenario: Allowed provider quotas shown with explicit permission - GIVEN a valid API key has `self:account-quota` -- AND it is restricted to exactly one Codex connection -- AND Codex quota data is available +- AND it is allowed to use Codex and Claude provider-limit connections +- AND quota data is available for both connections - WHEN it calls the self-service endpoint -- THEN the response SHALL include normalized `session` and `weekly` quota windows +- THEN the response SHALL include an `accountQuotas` entry for each allowed provider-limit connection - AND each window SHALL include used percentage, remaining percentage, and reset timestamp when known -#### Scenario: Multiple connections are ambiguous +#### Scenario: Single connection compatibility field - GIVEN a valid API key has `self:account-quota` -- AND it is allowed to use more than one connection +- AND it is allowed to use exactly one provider-limit connection - WHEN it calls the self-service endpoint -- THEN `accountQuota.available` SHALL be `false` -- AND `accountQuota.reason` SHALL be `ambiguous_connection` +- THEN the response SHALL include exactly one `accountQuotas` entry +- AND the response SHALL also include `accountQuota` with the same entry for backwards compatibility -#### Scenario: Unrestricted connections are ambiguous +#### Scenario: Unrestricted connections include active provider-limit connections - GIVEN a valid API key has `self:account-quota` - AND its `allowedConnections` list is empty, meaning all connections are allowed - WHEN it calls the self-service endpoint -- THEN `accountQuota.available` SHALL be `false` -- AND `accountQuota.reason` SHALL be `ambiguous_connection` +- THEN the response SHALL include `accountQuotas` entries for active provider-limit connections + +#### Scenario: Per-connection quota failure is isolated + +- GIVEN a valid API key has `self:account-quota` +- AND it is allowed to use two provider-limit connections +- AND one provider quota fetch fails +- WHEN it calls the self-service endpoint +- THEN the successful provider SHALL remain in `accountQuotas` +- AND the failed provider SHALL be represented with `available: false` and `reason: "fetch_failed"` + +#### Scenario: Provider connection lookup failure is isolated + +- GIVEN a valid API key has `self:account-quota` +- AND it is explicitly allowed to use two provider-limit connections +- AND one provider connection lookup fails before quota fetching +- WHEN it calls the self-service endpoint +- THEN the successful provider SHALL remain in `accountQuotas` +- AND the unresolved connection SHALL be represented with `available: false` and `reason: "connection_lookup_failed"` +- AND the response SHALL still include the key's own cost and token usage ### Requirement: Dashboard configuration diff --git a/docs/openspec/changes/self-service-api-key-usage/tasks.md b/docs/openspec/changes/self-service-api-key-usage/tasks.md index b7cb88ed5e..21f0848897 100644 --- a/docs/openspec/changes/self-service-api-key-usage/tasks.md +++ b/docs/openspec/changes/self-service-api-key-usage/tasks.md @@ -18,9 +18,10 @@ ## 3. Account Quota - [ ] Resolve account quota only when the key has `self:account-quota`. -- [ ] Use exactly one explicit allowed connection; treat unrestricted or multiple connections as ambiguous. -- [ ] Normalize Codex quota windows to `session` and `weekly`. -- [ ] Add tests for no scope, one connection, multiple connections, unsupported provider, and fetch failure. +- [ ] Enumerate all explicit allowed connections, or all active connections when `allowedConnections` is empty. +- [ ] Normalize quota windows for every provider-limit connection that returns quota data. +- [ ] Preserve the legacy `accountQuota` field when exactly one quota entry is returned. +- [ ] Add tests for no scope, one connection, multiple connections, unrestricted connections, unsupported provider, and fetch failure. ## 4. API Endpoint diff --git a/docs/poc/FREE_PROVIDERS_POC_REPORT.md b/docs/poc/FREE_PROVIDERS_POC_REPORT.md new file mode 100644 index 0000000000..fc3fc220bd --- /dev/null +++ b/docs/poc/FREE_PROVIDERS_POC_REPORT.md @@ -0,0 +1,157 @@ +# PoC Report: Per-Provider Unlimited Access for Free AI Providers + +**Date**: 2026-05-31 +**Status**: Phase 1 Complete + +--- + +## Executive Summary + +Tested 30+ free AI providers to find which ones offer truly unlimited access without API keys. Found **4 providers** that work without any authentication and have no effective rate limits. + +--- + +## Test Results + +### ✅ TRULY UNLIMITED (No Auth, No Rate Limit) + +| Provider | Model | Success Rate | Notes | +|----------|-------|--------------|-------| +| **OpenCode Free** | `nemotron-3-super-free` | 20/20 (100%) | No custom `rateLimit` in model config | +| **Pollinations** | `openai` | 17/20 (85%) | 502 errors (upstream), no 429s | +| **UncloseAI** | `adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic` | 10/10 (100%) | Any string as API key | +| **LLM7.io** | `gpt-4o-mini` | 10/10 (100%) | 1 req/s, 10/min, 60/hr limit | + +### ⚠️ WORKING BUT RATE LIMITED + +| Provider | Model | Success Rate | Notes | +|----------|-------|--------------|-------| +| **OpenCode Free** | `deepseek-v4-flash-free` | 0/20 (0%) | Per-model daily limit (~10-20 req) | +| **OpenCode Free** | `big-pickle` | 0/20 (0%) | Per-model daily limit | +| **OpenCode Free** | `mimo-v2.5-free` | 0/20 (0%) | Per-model daily limit | +| **t3.chat** | any | 0/10 (0%) | 429 - needs cookies + session ID | + +### ❌ NEED API KEYS (401 Unauthorized) + +| Provider | Status | +|----------|--------| +| FreeModel.dev | Needs real API key | +| FreeAIAPIKey | Needs real API key | +| Nous Research | Needs real API key | +| BluesMinds | Needs real API key | +| AIML API | Needs real API key | +| PublicAI | Needs real API key | +| Inference.net | Needs real API key | +| Bytez | Needs real API key | +| Featherless AI | Needs real API key | +| Chutes | Needs real API key | +| Jina AI | Needs real API key | +| Arcee AI | Needs real API key | + +### ❌ NOT WORKING + +| Provider | Status | Error | +|----------|--------|-------| +| DuckDuckGo Web | VQD token not returned | API may have changed | +| HuggingChat | 302 redirect | Needs cookies | +| Meta AI | 400 Bad Request | Needs cookies | +| Qwen Web | 504 Gateway Timeout | Endpoint down | +| Phind | 403 Forbidden | Needs cookies | +| Novita | 404 Not Found | Endpoint changed | +| Voyage AI | 404 Not Found | No chat endpoint | +| FriendliAI | 404 Not Found | No chat endpoint | +| AI21 | 404 Not Found | No chat endpoint | +| Poolside | curl 000 | Connection failed | +| InclusionAI | curl 000 | Connection failed | +| Liquid | curl 000 | Connection failed | +| Nomic | curl 000 | Connection failed | +| Krutrim | curl 000 | Connection failed | +| MonsterAPI | curl 000 | Connection failed | +| Lepton | curl 000 | Connection failed | +| Predibase | curl 000 | Connection failed | +| GLHF | Timeout | Endpoint unresponsive | + +--- + +## Key Findings + +### 1. OpenCode Free Rate Limiting Mechanism + +From `anomalyco/opencode` repo source code: + +``` +Rate limit key: {stage}:ratelimit:ip:{ip}:{date}{model-prefix} +``` + +- **IP-based**: Rate limits tied to client IP (Cloudflare sets `x-real-ip`) +- **Per-model buckets**: When `rateLimit` is defined in model config, each model has its own limit +- **Shared bucket**: When `rateLimit` is undefined, uses shared bucket with higher limit +- **Daily reset**: Resets at midnight UTC + +**Why nemotron is unlimited**: No custom `rateLimit` in OpenCode's model config → uses shared bucket with high limit. + +**Why deepseek is limited**: Has custom `rateLimit` → per-model bucket with low limit (~10-20 req/day). + +### 2. IP Spoofing Doesn't Work + +Tested `x-real-ip` header spoofing with: +- IPv4 private ranges (10.x, 192.168.x, 172.16.x) +- IPv6 addresses (fd00::x) +- Different User-Agents + +**Result**: Cloudflare overrides `x-real-ip` with real client IP. All spoofed IPs ignored. + +### 3. Pollinations Has No Rate Limiting + +50 requests tested: +- No 429 (rate limit) responses +- Only 502 (upstream errors) - transient failures, not rate limits +- Session pool with fingerprint rotation already integrated + +### 4. UncloseAI Works With Any Key + +- Accepts any non-empty string as API key +- No rate limiting detected (10/10 success) +- Models: `adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic`, `qwen3.6:27b`, `gemma4:31b` + +### 5. LLM7.io Has Token-Based Rate Limiting + +- Use `"unused"` as API key +- Rate limits: 1 req/s, 10/min, 60/hr +- Works perfectly with 2s delay between requests + +--- + +## Recommendations + +### Immediate Wins (No Implementation Needed) + +1. **Use `nemotron-3-super-free`** via OpenCode Free - already unlimited +2. **Use Pollinations** - already has session pool, no rate limits +3. **Use UncloseAI** - any key works, no rate limits +4. **Use LLM7.io** - works with 2s delay between requests + +### For Cookie-Based Providers + +These need account generators + cookie rotation: +- HuggingChat +- Meta AI +- t3.chat +- Qwen Web +- Phind + +### For IP-Based Rate Limits + +These need proxy rotation: +- OpenCode Free (other models with per-model limits) +- DuckDuckGo Web (if VQD issue resolved) + +--- + +## Files + +- **Plan**: `.omo/plans/unlimited-free-providers.md` +- **PoC Script**: `tests/poc/session-pool-opencode-poc.ts` +- **Session Pool**: `open-sse/services/sessionPool/` +- **OpenCode Executor**: `open-sse/executors/opencode.ts` +- **Pollinations Executor**: `open-sse/executors/pollinations.ts` diff --git a/docs/poc/OPENCODE_FREE_SESSION_POOL_POC.md b/docs/poc/OPENCODE_FREE_SESSION_POOL_POC.md new file mode 100644 index 0000000000..22007885a0 --- /dev/null +++ b/docs/poc/OPENCODE_FREE_SESSION_POOL_POC.md @@ -0,0 +1,95 @@ +# PoC: SessionPool × OpenCode Free — IP Spoofing for Unlimited Access + +## Summary + +This PoC tests whether the SessionPool's fingerprint rotation combined with IP spoofing can bypass rate limiting on the OpenCode Free public endpoint (`https://opencode.ai/zen/v1/chat/completions`). + +**Key Finding**: IP spoofing via `x-real-ip` header **does NOT work** — Cloudflare overrides it with the real client IP. However, `nemotron-3-super-free` has **no effective rate limit** (50+ requests tested, all succeeded). Other free models (deepseek, big-pickle, mimo) have per-model daily limits. + +## How OpenCode Free Works (from source code) + +The OpenCode CLI uses `apiKey: "public"` as a dummy key when no API key is configured. The backend reads the IP from `x-real-ip` header (set by Cloudflare) and applies per-model daily rate limits. + +### Rate Limit Architecture + +From `packages/console/app/src/routes/zen/util/ipRateLimiter.ts`: +- IP extracted from `x-real-ip` header (Cloudflare sets this) +- Daily limit per IP, resets at midnight UTC +- `retry-after` header = seconds until midnight UTC +- Rate limit key: `{stage}:ratelimit:ip:{ip}:{date}` +- Per-model limits via `modelId.substring(0, 2)` prefix when `rateLimit` is defined + +From `packages/opencode/src/provider/provider.ts`: +- When no API key: filters to free models only (`cost.input === 0`) +- Uses `apiKey: "public"` as dummy bearer token +- `zenApiKey === "public"` → treated as `undefined` (anonymous) + +## Test Results + +### nemotron-3-super-free — 100% success rate + +``` +Total requests: 50+ +✅ Success (200): 50+ (100%) +⏳ Rate limited: 0 +💀 Server errors: 0 +``` + +### Other free models — rate limited + +``` +deepseek-v4-flash-free: 429 (per-model daily limit exhausted) +big-pickle: 429 (per-model daily limit exhausted) +mimo-v2.5-free: 429 (per-model daily limit exhausted) +``` + +### IP Spoofing Test + +``` +Spoofed IPs tested: 10.0.0.1-255, 192.168.x.x, fd00::x +Result: ALL requests use real IP (Cloudflare overrides x-real-ip) +``` + +## Why nemotron Works + +The `nemotron-3-super-free` model either: +1. Has no custom `rateLimit` defined (uses shared bucket with high limit) +2. Has a very high per-model daily limit (50+) +3. Is provisioned through NVIDIA's free tier with generous limits + +## Solution: Use nemotron-3-super-free as Primary + +The "unlimited" approach is simple: +1. Use `nemotron-3-super-free` as the primary model (no rate limit) +2. Fall back to other free models when needed +3. Use the session pool for request management and retry logic + +## Implementation Plan + +1. Update OmniRoute's OpenCode executor to prefer `nemotron-3-super-free` +2. Add fallback chain: nemotron → deepseek → big-pickle → mimo +3. Use session pool for request queuing and automatic model rotation +4. Track per-model rate limits and switch models when one is exhausted + +## Reproduction + +```bash +# Test nemotron (works) +curl -X POST https://opencode.ai/zen/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer public" \ + -d '{"model":"nemotron-3-super-free","messages":[{"role":"user","content":"Say ok"}],"stream":false}' + +# Test deepseek (rate limited after ~10 requests) +curl -X POST https://opencode.ai/zen/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer public" \ + -d '{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"Say ok"}],"stream":false}' +``` + +## Files + +- **PoC script**: `tests/poc/session-pool-opencode-poc.ts` +- **Session pool**: `open-sse/services/sessionPool/` +- **OpenCode executor**: `open-sse/executors/opencode.ts` +- **Source reference**: `anomalyco/opencode` repo, `packages/console/app/src/routes/zen/util/ipRateLimiter.ts` diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 0884a2466c..b4a94aaed7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -436,6 +436,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | | `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | @@ -617,7 +618,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `256` (Docker) / system default | V8 heap limit. Sets `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index c280d33bc4..8adeb3c3de 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -88,10 +88,10 @@ Applied to: `system` blocks, all `messages[].content`, and `tools[].description` For third-party Anthropic relays that only accept "real Claude Code" traffic: -- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.146 (external, sdk-cli)"` -- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.158 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"` -- `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` +- `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24,redact-thinking-2026-02-12"` - `CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"` (Opus/Sonnet 4.x family) - Default path: `/v1/messages?beta=true` @@ -212,7 +212,7 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.146 (external, cli)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.158 (external, cli)` | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | diff --git a/docs/specs/2026-05-29-self-service-api-key-usage-design.md b/docs/specs/2026-05-29-self-service-api-key-usage-design.md index 5a3966f271..64522ed1b8 100644 --- a/docs/specs/2026-05-29-self-service-api-key-usage-design.md +++ b/docs/specs/2026-05-29-self-service-api-key-usage-design.md @@ -4,7 +4,7 @@ Operators often share one upstream coding account, such as Codex, across multiple OmniRoute API keys. OmniRoute already records per-key usage and supports per-key USD budgets, but a normal client API key cannot query its own spend or token totals. The existing usage APIs are management endpoints, so exposing them to each API key would disclose other keys, account metadata, and operational settings. -Operators also need a way to decide whether a key may see the shared upstream account quota. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs. That quota is account-level state, not key-level state, so it should not be visible by default. +Operators also need a way to decide whether a key may see shared upstream account quotas. For Codex this includes the short session window and weekly window fetched from ChatGPT usage APIs, and other subscription providers can expose their own normalized provider-limit windows. That quota is account-level state, not key-level state, so it should not be visible by default. The goal is to add a small self-service status API and matching dashboard controls so a delegated API key can see: @@ -29,7 +29,7 @@ Relevant current implementation: - `/api/v1/*` routes are public from the route classifier perspective, but individual handlers still validate Bearer API keys. - Per-key USD budgets already exist through `domain_budgets`, `domain_cost_history`, `getCostSummary(apiKeyId)`, and `checkBudget(apiKeyId)`. - Token usage is already recorded per key in `usage_history.api_key_id` with input, output, cache read, cache creation, and reasoning token columns. -- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and Codex quota support in `open-sse/services/codexQuotaFetcher.ts` / `open-sse/services/usage.ts`. +- Provider quota data is fetched through `src/lib/usage/providerLimits.ts` and provider usage support in `open-sse/services/usage.ts`. - The API Manager UI currently has a management-access toggle on create/edit and sends `scopes: ["manage"]` or `[]`; the edit modal must be changed before adding more scope types so it does not discard unrelated scopes. ## Goals @@ -101,16 +101,45 @@ The response contains only the caller's own API key identity, budget usage, toke "totalTokens": 1067000 } }, + "accountQuotas": [ + { + "provider": "codex", + "connectionId": "conn_123", + "shared": true, + "plan": "ChatGPT Plus", + "quotas": { + "session": { + "remainingPercentage": 99, + "usedPercentage": 1, + "resetAt": "2026-05-29T18:11:44.000Z" + }, + "weekly": { + "remainingPercentage": 3, + "usedPercentage": 97, + "resetAt": "2026-05-31T01:23:38.000Z" + } + } + }, + { + "provider": "claude", + "connectionId": "conn_456", + "shared": true, + "plan": "Claude Max", + "quotas": { + "daily": { + "remainingPercentage": 65, + "usedPercentage": 35, + "resetAt": "2026-05-30T00:00:00.000Z" + } + } + } + ], "accountQuota": { "provider": "codex", "connectionId": "conn_123", "shared": true, + "plan": "ChatGPT Plus", "quotas": { - "session": { - "remainingPercentage": 99, - "usedPercentage": 1, - "resetAt": "2026-05-29T18:11:44.000Z" - }, "weekly": { "remainingPercentage": 3, "usedPercentage": 97, @@ -121,25 +150,53 @@ The response contains only the caller's own API key identity, budget usage, toke } ``` -`accountQuota` is omitted unless the key has the account quota scope. If the scope is present but the connection cannot be resolved safely, return: +`accountQuotas` is omitted unless the key has the account quota scope. `accountQuota` is retained as a compatibility alias only when exactly one account quota entry is returned. If a specific allowed connection cannot fetch quota data, include a per-connection unavailable entry: ```json { + "accountQuotas": [ + { + "provider": "cursor", + "connectionId": "conn_789", + "shared": true, + "available": false, + "reason": "fetch_failed" + } + ], "accountQuota": { + "provider": "cursor", + "connectionId": "conn_789", + "shared": true, "available": false, - "reason": "ambiguous_connection" + "reason": "fetch_failed" } } ``` -Use stable reason strings: `not_supported`, `ambiguous_connection`, `no_allowed_connection`, `not_available`, and `fetch_failed`. +If explicit connection metadata lookup fails before the provider is known, return the unresolved connection as unavailable without failing the whole status response: + +```json +{ + "accountQuotas": [ + { + "provider": "unknown", + "connectionId": "conn_789", + "shared": true, + "available": false, + "reason": "connection_lookup_failed" + } + ] +} +``` + +Use stable reason strings: `not_supported`, `not_available`, `fetch_failed`, and `connection_lookup_failed`. ## Scopes Add self-service scopes that do not grant management access: - `self:usage`: allows a key to query its own spend, budget percent, and token totals. -- `self:account-quota`: allows a key to see shared upstream account quota for its resolved connection. +- `self:account-quota`: allows a key to see shared upstream account quotas for provider-limit connections it may use. `self:usage` should be enabled by default for newly created ordinary API keys. The UI should show it checked by default and persist the scope when the control is enabled. For backwards compatibility, the implementation should backfill `self:usage` onto existing ordinary keys during migration or first startup after upgrade. After that compatibility step, absence of `self:usage` means own-usage visibility is disabled and the self-service endpoint returns `403`. @@ -181,19 +238,21 @@ WHERE api_key_id = ? Account quota is shared provider state. The self-service endpoint may include it only when: - The API key has `self:account-quota`. -- A single provider connection can be resolved without ambiguity. -- The provider supports quota fetching. +- Provider connections can be resolved from the key's allowed connection policy. +- The providers support quota fetching through the provider limits path. Connection resolution must follow the source semantics for `allowedConnections`: an empty array means unrestricted access to all connections, not "no connections". -- If exactly one explicit allowed connection exists and it resolves to a quota-supported provider, use that connection. -- If `allowedConnections` is empty, treat the connection scope as ambiguous and return `available: false` with `ambiguous_connection`. This avoids exposing shared quota for a broad/unrestricted key. -- If explicit allowed connection ids are present but none resolve, return `available: false` with `no_allowed_connection`. -- If multiple explicit allowed connections exist, return `available: false` with `ambiguous_connection`. +- If explicit allowed connection ids are present, fetch quota data for those active provider-limit connections. +- If `allowedConnections` is empty, fetch quota data for all active provider-limit connections because the key may use all of them. +- If an allowed connection is inactive, missing, or unsupported, skip it or return a per-connection `not_supported` entry when the connection identity is known. +- If explicit connection lookup fails, keep the rest of the response and return that connection as `available: false` with `connection_lookup_failed`. +- If unrestricted connection listing fails, keep the rest of the response and return an empty `accountQuotas` array because no allowed connection identities can be resolved. +- If a provider quota fetch fails, keep the rest of the response and return that connection as `available: false` with `fetch_failed`. -This conservative rule avoids accidentally exposing quota for an account the key may not actually use. A later change can add an explicitly authorized `?connectionId=` flow if there is demand for multi-connection keys. +This rule matches routing permissions: the endpoint exposes only account quotas for connections the key is allowed to use, and only when the operator explicitly grants `self:account-quota`. -For Codex, reuse the existing provider limits / Codex quota path. Normalize Codex windows to `session` and `weekly` and return used/remaining percentages plus reset timestamps. Do not return raw upstream payloads. +Reuse the existing provider limits path. Normalize every returned quota window to used/remaining percentages plus reset timestamps. Do not return raw upstream payloads. ## Dashboard UX @@ -218,7 +277,7 @@ Usage display: - In the key list or details panel, show USD used, active USD limit, and used percent when a budget exists. - Show token totals in a compact details view. -- Show shared account quota only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota. +- Show shared account quotas only for keys with `self:account-quota`, clearly labeled as shared account quota, not per-key quota. - When no USD budget is configured, show usage normally and render the limit, remaining amount, and percent as unset/not configured rather than `0%`. ## Internationalization @@ -287,7 +346,7 @@ Never include: - Upstream access tokens or refresh tokens. - Provider account email unless that email is already visible to this key through another client API. - Other keys' spend, token totals, names, or budgets. -- Raw ChatGPT/Codex usage payloads. +- Raw upstream usage payloads. Account quota should be treated as sensitive because it lets delegated users infer shared account exhaustion. The default remains off. @@ -297,7 +356,7 @@ Account quota should be treated as sensitive because it lets delegated users inf - Valid key without `self:usage`: `403`. - Budget missing: `200` with null limit and percent fields. - Usage aggregation failure: `500` with generic message; log server-side details. -- Quota fetch unsupported or unavailable: `200` with `accountQuota.available: false`. +- Quota fetch unsupported or unavailable: `200` with per-connection unavailable entries in `accountQuotas`. - Quota fetch auth failure: do not leak provider auth details; return `not_available` or `fetch_failed` and log details server-side. ## Testing @@ -310,9 +369,11 @@ Add focused tests: - A normal key with `self:usage` can query its own cost and token totals without `manage`. - The endpoint never accepts an `apiKeyId` override. - Key A cannot see Key B usage. -- A key without account quota scope does not receive `accountQuota`. -- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota. -- Unrestricted or multiple allowed connections return `ambiguous_connection`. +- A key without account quota scope does not receive `accountQuotas`. +- A key with account quota scope and one allowed Codex connection receives normalized session and weekly quota plus the compatibility `accountQuota` field. +- A key with account quota scope and multiple allowed provider-limit connections receives multiple `accountQuotas` entries. +- A key with account quota scope and unrestricted connection access receives all active provider-limit connection quotas. +- A failed provider quota fetch returns an unavailable entry without hiding successful provider quota entries. - Create UI defaults own usage on and shared quota off. - Edit UI preserves unrelated scopes. - UI renders the no-budget state as not configured, with usage and token totals still visible. diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 5f708a5a80..091b00a688 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -29,7 +29,7 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [ ...ANTHROPIC_BETA_BASE.slice(3), ].join(","); -export const CLAUDE_CLI_VERSION = "2.1.146"; +export const CLAUDE_CLI_VERSION = "2.1.158"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index dc84b12305..338b6fb485 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -22,11 +22,7 @@ export interface AudioProvider { models: AudioModel[]; } -let _AUDIO_TRANSCRIPTION_PROVIDERS: Record | null = null; - -function getOrCreateTranscriptionProviders(): Record { - if (!_AUDIO_TRANSCRIPTION_PROVIDERS) { - _AUDIO_TRANSCRIPTION_PROVIDERS = { +export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/audio/transcriptions", @@ -149,56 +145,9 @@ function getOrCreateTranscriptionProviders(): Record { { id: "elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" }, ], }, - }; -} - return _AUDIO_TRANSCRIPTION_PROVIDERS; -} +}; -export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateTranscriptionProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateTranscriptionProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateTranscriptionProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateTranscriptionProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateTranscriptionProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateTranscriptionProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateTranscriptionProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getTranscriptionProviders(): Record { - return AUDIO_TRANSCRIPTION_PROVIDERS; -} - -let _AUDIO_SPEECH_PROVIDERS: Record | null = null; - -function getOrCreateSpeechProviders(): Record { - if (!_AUDIO_SPEECH_PROVIDERS) { - _AUDIO_SPEECH_PROVIDERS = { +export const AUDIO_SPEECH_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/audio/speech", @@ -418,57 +367,22 @@ function getOrCreateSpeechProviders(): Record { { id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" }, ], }, - }; -} - return _AUDIO_SPEECH_PROVIDERS; -} - -export const AUDIO_SPEECH_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateSpeechProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateSpeechProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateSpeechProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateSpeechProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateSpeechProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateSpeechProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateSpeechProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getSpeechProviders(): Record { - return AUDIO_SPEECH_PROVIDERS; -} +}; +/** + * Get transcription provider config by ID + */ export function getTranscriptionProvider(providerId: string): AudioProvider | null { return AUDIO_TRANSCRIPTION_PROVIDERS[providerId] || null; } + +/** + * Get speech provider config by ID + */ export function getSpeechProvider(providerId: string): AudioProvider | null { return AUDIO_SPEECH_PROVIDERS[providerId] || null; } + export interface ProviderNodeRow { prefix: string; name: string; diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 1053bb6f02..69994c323a 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -45,11 +45,7 @@ export function buildDynamicEmbeddingProvider(node: EmbeddingProviderNodeRow): E }; } -let _EMBEDDING_PROVIDERS: Record | null = null; - -function getOrCreateEmbeddingProviders(): Record { - if (!_EMBEDDING_PROVIDERS) { - _EMBEDDING_PROVIDERS = { +export const EMBEDDING_PROVIDERS: Record = { cohere: { id: "cohere", baseUrl: "https://api.cohere.com/v2/embed", @@ -237,50 +233,7 @@ function getOrCreateEmbeddingProviders(): Record { { id: "jina-colbert-v2", name: "Jina ColBERT v2", dimensions: 128 }, ], }, - }; - } - return _EMBEDDING_PROVIDERS; -} - -export const EMBEDDING_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateEmbeddingProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateEmbeddingProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateEmbeddingProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateEmbeddingProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateEmbeddingProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateEmbeddingProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateEmbeddingProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getEmbeddingProviders(): Record { - return EMBEDDING_PROVIDERS; -} +}; const EMBEDDING_PROVIDER_ALIASES: Record = { jina: "jina-ai", diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index d37aa2c509..a32ce55638 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -112,11 +112,7 @@ function findImageModelConfig(providerId, modelId) { return provider.models.find((model) => model.id === modelId) || null; } -let _IMAGE_PROVIDERS: Record | null = null; - -function getOrCreateImageProviders(): Record { - if (!_IMAGE_PROVIDERS) { - _IMAGE_PROVIDERS = { +export const IMAGE_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/images/generations", @@ -544,53 +540,14 @@ function getOrCreateImageProviders(): Record { supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], }, }; -} -return _IMAGE_PROVIDERS; -} - -export function getImageProviders(): Record { - return IMAGE_PROVIDERS; -} - -export const IMAGE_PROVIDERS = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateImageProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateImageProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateImageProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateImageProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateImageProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateImageProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateImageProviders()[key as string] }; - } - return undefined; - }, -}); +/** + * Get image provider config by ID + */ export function getImageProvider(providerId) { return IMAGE_PROVIDERS[providerId] || null; } + /** * Parse image model string (format: "provider/model") * Returns { provider, model } diff --git a/open-sse/config/moderationRegistry.ts b/open-sse/config/moderationRegistry.ts index 7ff3ff8db5..4a3f621998 100644 --- a/open-sse/config/moderationRegistry.ts +++ b/open-sse/config/moderationRegistry.ts @@ -5,11 +5,7 @@ * Follows OpenAI's moderation API format. */ -let _MODERATION_PROVIDERS: Record | null = null; - -function getOrCreateModerationProviders(): Record { - if (!_MODERATION_PROVIDERS) { - _MODERATION_PROVIDERS = { +export const MODERATION_PROVIDERS = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/moderations", @@ -20,55 +16,18 @@ function getOrCreateModerationProviders(): Record { { id: "text-moderation-latest", name: "Text Moderation Latest" }, ], }, - }; - } - return _MODERATION_PROVIDERS; -} - -export const MODERATION_PROVIDERS = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateModerationProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateModerationProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateModerationProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateModerationProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateModerationProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateModerationProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateModerationProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getModerationProviders(): Record { - return MODERATION_PROVIDERS; -} +}; +/** + * Get moderation provider config by ID + */ export function getModerationProvider(providerId) { return MODERATION_PROVIDERS[providerId] || null; } +/** + * Parse moderation model string + */ export function parseModerationModel(modelStr) { if (!modelStr) return { provider: null, model: null }; @@ -87,6 +46,9 @@ export function parseModerationModel(modelStr) { return { provider: null, model: modelStr }; } +/** + * Get all moderation models as a flat list + */ export function getAllModerationModels() { const models = []; for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 492eb444bb..7ca68d5891 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -23,11 +23,7 @@ interface MusicProvider { models: MusicModel[]; } -let _MUSIC_PROVIDERS: Record | null = null; - -function getOrCreateMusicProviders(): Record { - if (!_MUSIC_PROVIDERS) { - _MUSIC_PROVIDERS = { +export const MUSIC_PROVIDERS: Record = { kie: { id: "kie", baseUrl: "https://api.kie.ai", @@ -86,59 +82,25 @@ function getOrCreateMusicProviders(): Record { { id: "musicgen-medium", name: "MusicGen Medium" }, ], }, - }; -} - return _MUSIC_PROVIDERS; -} - -export const MUSIC_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateMusicProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateMusicProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateMusicProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateMusicProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateMusicProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateMusicProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateMusicProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getMusicProviders(): Record { - return MUSIC_PROVIDERS; -} +}; +/** + * Get music provider config by ID + */ export function getMusicProvider(providerId: string): MusicProvider | null { return MUSIC_PROVIDERS[providerId] || null; } +/** + * Parse music model string (format: "provider/model" or just "model") + */ export function parseMusicModel(modelStr: string | null) { return parseModelFromRegistry(modelStr, MUSIC_PROVIDERS); } +/** + * Get all music models as a flat list + */ export function getAllMusicModels() { return getAllModelsFromRegistry(MUSIC_PROVIDERS); -} \ No newline at end of file +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ea46aa9a2..8e9a50e666 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -56,6 +56,13 @@ export interface RegistryModel { unsupportedParams?: readonly string[]; /** Maximum context window in tokens */ contextLength?: number; + /** + * Interleaved-reasoning signal, mirroring models.dev's `interleaved_field`. + * Set to "reasoning_content" for models whose upstream runs DeepSeek thinking + * mode (e.g. OpenCode `big-pickle`) so follow-up/tool-use turns replay + * reasoning_content instead of failing with a DeepSeek 400 (#2900). + */ + interleavedField?: string; } // Reasoning models reject temperature, top_p, penalties, logprobs, n. @@ -111,6 +118,8 @@ export interface RegistryEntry { passthroughModels?: boolean; /** Default context window for all models in this provider (can be overridden per-model) */ defaultContextLength?: number; + /** Optional session pool config for rate limit management */ + poolConfig?: Record; } interface LegacyProvider { @@ -580,7 +589,7 @@ function mapStainlessArch() { // ── Registry ────────────────────────────────────────────────────────────── -export const REGISTRY: Record = { +const _REGISTRY_EAGER: Record = { // ─── OAuth Providers ─────────────────────────────────────────────────── kie: { id: "kie", @@ -976,9 +985,10 @@ export const REGISTRY: Record = { maxOutputTokens: 64000, }, { + // #2911: GitHub Copilot's Responses API does not serve Claude/Gemini — + // route them via chat/completions (provider default) like claude-opus-4.6. id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Full ID)", - targetFormat: "openai-responses", contextLength: 200000, maxOutputTokens: 64000, }, @@ -989,14 +999,15 @@ export const REGISTRY: Record = { maxOutputTokens: 128000, }, { + // #2911: Claude on Copilot must use chat/completions, not the Responses API. id: "claude-opus-4.7", name: "Claude Opus 4.7", - targetFormat: "openai-responses", contextLength: 1000000, maxOutputTokens: 128000, }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", targetFormat: "openai-responses" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", targetFormat: "openai-responses" }, + // #2911: Gemini on Copilot must use chat/completions, not the Responses API. + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, { id: "oswe-vscode-prime", name: "Raptor Mini", targetFormat: "openai-responses" }, //{ id: "?", name: "Goldeneye" }, ], @@ -1121,6 +1132,27 @@ export const REGISTRY: Record = { ], }, + trae: { + id: "trae", + alias: "tr", + format: "openai", + executor: "trae", + baseUrl: "https://core-normal.trae.ai/api/remote/v1", + authType: "oauth", + authHeader: "bearer", + defaultContextLength: 272000, + models: [ + { id: "auto", name: "Auto (Code · Server Picks)" }, + { id: "work", name: "Work (Auto · fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.4", name: "GPT 5.4" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + ], + }, + cursor: { id: "cursor", alias: "cu", @@ -1290,7 +1322,11 @@ export const REGISTRY: Record = { passthroughModels: true, defaultContextLength: 200000, models: [ - { id: "big-pickle", name: "Big Pickle" }, + // #2900: big-pickle's upstream runs DeepSeek thinking mode — declare the + // interleaved reasoning_content contract so follow-up/tool-use turns replay + // it (otherwise DeepSeek returns 400 "reasoning_content ... must be passed back"). + { id: "big-pickle", name: "Big Pickle", supportsReasoning: true, interleavedField: "reasoning_content" }, + { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true }, { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 }, { id: "ling-2.6-1t-free", name: "Ling 2.6 Free", contextLength: 262000 }, { @@ -1299,6 +1335,7 @@ export const REGISTRY: Record = { contextLength: 131000, }, { id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", contextLength: 1000000 }, + { id: "qwen3.6-plus-free", name: "Qwen3.6 Plus Free", targetFormat: "claude", supportsVision: false, contextLength: 200000, }, ], }, @@ -1357,7 +1394,10 @@ export const REGISTRY: Record = { passthroughModels: true, models: [ // ── Chat / Coding ────────────────────────────────────────── - { id: "big-pickle", name: "Big Pickle" }, + // #2900: big-pickle's upstream runs DeepSeek thinking mode — declare the + // interleaved reasoning_content contract so follow-up/tool-use turns replay + // it (otherwise DeepSeek returns 400 "reasoning_content ... must be passed back"). + { id: "big-pickle", name: "Big Pickle", supportsReasoning: true, interleavedField: "reasoning_content" }, { id: "gpt-5-nano", name: "GPT 5 Nano", contextLength: 400000 }, { id: "gpt-5", name: "GPT 5" }, { id: "gpt-5-codex", name: "GPT 5 Codex" }, @@ -3424,13 +3464,11 @@ export const REGISTRY: Record = { alias: "pol", format: "openai", executor: "pollinations", - // Primary endpoint is text.pollinations.ai. gen.pollinations.ai is the current - // OpenAI-compatible fallback used when the primary edge is rate-limited or unavailable. - baseUrl: "https://text.pollinations.ai/openai/chat/completions", - baseUrls: [ - "https://text.pollinations.ai/openai/chat/completions", - "https://gen.pollinations.ai/v1/chat/completions", - ], + // #2987: Pollinations retired the legacy text.pollinations.ai host (it now + // returns 404 "This is our legacy API"). The current OpenAI-compatible gateway + // is gen.pollinations.ai/v1, so route there as the primary endpoint. + baseUrl: "https://gen.pollinations.ai/v1/chat/completions", + baseUrls: ["https://gen.pollinations.ai/v1/chat/completions"], authType: "apikey", authHeader: "bearer", models: [ @@ -3940,6 +3978,26 @@ export const REGISTRY: Record = { ], }, + "qwen-web": { + id: "qwen-web", + alias: "qw", + format: "openai", + executor: "qwen-web", + baseUrl: "https://chat.qwen.ai/api/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "qwen-plus", name: "Qwen Plus" }, + { id: "qwen-max", name: "Qwen Max" }, + { id: "qwen-turbo", name: "Qwen Turbo" }, + { id: "qwen3-plus", name: "Qwen3 Plus" }, + { id: "qwen3-max", name: "Qwen3 Max" }, + { id: "qwen3-flash", name: "Qwen3 Flash" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, + ], + }, + codestral: { id: "codestral", alias: "codestral", @@ -4077,6 +4135,15 @@ export const REGISTRY: Record = { modelsUrl: "https://api.llm7.io/v1/models", authType: "apikey", authHeader: "bearer", + poolConfig: { + minSessions: 1, + maxSessions: 3, + cooldownBase: 2000, + cooldownMax: 5000, + cooldownJitter: 100, + requestTimeout: 30000, + requestJitter: 50, + }, models: [ { id: "gpt-4o-mini-2024-07-18", name: "GPT-4o mini (LLM7)" }, { id: "gpt-4.1-nano-2025-04-14", name: "GPT-4.1 nano (LLM7)" }, @@ -4227,12 +4294,14 @@ export const REGISTRY: Record = { }, }; +export const REGISTRY: Record = _REGISTRY_EAGER; + // ── Generator Functions ─────────────────────────────────────────────────── /** Generate legacy PROVIDERS object shape for constants.js backward compatibility */ export function generateLegacyProviders(): Record { const providers: Record = {}; - for (const [id, entry] of Object.entries(REGISTRY)) { + for (const [id, entry] of Object.entries(_REGISTRY_EAGER)) { const p: LegacyProvider = { format: entry.format }; // URL(s) @@ -4286,7 +4355,7 @@ export function generateLegacyProviders(): Record { /** Generate PROVIDER_MODELS map (alias → model list) */ export function generateModels(): Record { const models: Record = {}; - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { if (entry.models && entry.models.length > 0) { const key = entry.alias || entry.id; // If alias already exists, don't overwrite (first wins) @@ -4301,7 +4370,7 @@ export function generateModels(): Record { /** Generate PROVIDER_ID_TO_ALIAS map */ export function generateAliasMap(): Record { const map: Record = {}; - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { map[entry.id] = entry.alias || entry.id; } return map; @@ -4343,33 +4412,40 @@ export function isLocalProvider(baseUrl?: string | null): boolean { } /** Set of provider IDs with passthroughModels enabled — 404s are model-specific, not account-level. */ -const _passthroughProviderIds: Set | null = (() => { +let _passthroughProviderIds: Set | null = null; +function ensurePassthroughProviderIds(): Set { + if (_passthroughProviderIds) return _passthroughProviderIds; try { const ids = new Set(); - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { if (entry.passthroughModels) ids.add(entry.id); } - return ids; + _passthroughProviderIds = ids; } catch { - return null; + _passthroughProviderIds = new Set(); } -})(); - + return _passthroughProviderIds; +} export function getPassthroughProviders(): Set { - return _passthroughProviderIds ?? new Set(); + return ensurePassthroughProviderIds(); } // ── Registry Lookup Helpers ─────────────────────────────────────────────── const _byAlias = new Map(); -for (const entry of Object.values(REGISTRY)) { - if (entry.alias && entry.alias !== entry.id) { - _byAlias.set(entry.alias, entry); +let _byAliasPopulated = false; +function ensureByAliasPopulated(): void { + if (_byAliasPopulated) return; + _byAliasPopulated = true; + for (const entry of Object.values(_REGISTRY_EAGER)) { + if (entry.alias && entry.alias !== entry.id) { + _byAlias.set(entry.alias, entry); + } } } - /** Get registry entry by provider ID or alias */ export function getRegistryEntry(provider: string): RegistryEntry | null { + ensureByAliasPopulated(); return REGISTRY[provider] || _byAlias.get(provider) || null; } @@ -4381,10 +4457,15 @@ export function getRegisteredProviders(): string[] { // Precomputed map: modelId → unsupportedParams (O(1) lookup instead of O(N×M) scan). // Built once at module load from all registry entries. const _unsupportedParamsMap = new Map(); -for (const entry of Object.values(REGISTRY)) { - for (const model of entry.models) { - if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) { - _unsupportedParamsMap.set(model.id, model.unsupportedParams); +let _unsupportedParamsPopulated = false; +function ensureUnsupportedParamsPopulated(): void { + if (_unsupportedParamsPopulated) return; + _unsupportedParamsPopulated = true; + for (const entry of Object.values(_REGISTRY_EAGER)) { + for (const model of entry.models) { + if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) { + _unsupportedParamsMap.set(model.id, model.unsupportedParams); + } } } } @@ -4396,6 +4477,7 @@ for (const entry of Object.values(REGISTRY)) { * Returns empty array if no restrictions are defined. */ export function getUnsupportedParams(provider: string, modelId: string): readonly string[] { + ensureUnsupportedParamsPopulated(); // 1. Check current provider's registry (exact match) const entry = getRegistryEntry(provider); const modelEntry = entry?.models.find((m) => m.id === modelId); diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index 431afcf6ca..bf14c0cc23 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -8,11 +8,7 @@ * keyed by provider ID (e.g. "cohere", "together"). */ -let _RERANK_PROVIDERS: Record | null = null; - -function getOrCreateRerankProviders(): Record { - if (!_RERANK_PROVIDERS) { - _RERANK_PROVIDERS = { +export const RERANK_PROVIDERS = { cohere: { id: "cohere", baseUrl: "https://api.cohere.com/v2/rerank", @@ -75,50 +71,7 @@ function getOrCreateRerankProviders(): Record { { id: "jina-reranker-m0", name: "Jina Reranker m0" }, ], }, - }; -} - return _RERANK_PROVIDERS; -} - -export const RERANK_PROVIDERS = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateRerankProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateRerankProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateRerankProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateRerankProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateRerankProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateRerankProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateRerankProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getRerankProviders(): Record { - return RERANK_PROVIDERS; -} +}; const RERANK_PROVIDER_ALIASES = { jina: "jina-ai", diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index b33239a559..724d349525 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -26,11 +26,7 @@ export interface SearchProviderConfig { cacheTTLMs: number; } -let _SEARCH_PROVIDERS: Record | null = null; - -function getOrCreateSearchProviders(): Record { - if (!_SEARCH_PROVIDERS) { - _SEARCH_PROVIDERS = { +export const SEARCH_PROVIDERS: Record = { "serper-search": { id: "serper-search", name: "Serper Search", @@ -222,52 +218,14 @@ function getOrCreateSearchProviders(): Record { timeoutMs: 10_000, cacheTTLMs: 5 * 60 * 1000, }, - }; -} - return _SEARCH_PROVIDERS; -} +}; -export const SEARCH_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateSearchProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateSearchProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateSearchProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateSearchProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateSearchProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateSearchProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateSearchProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getSearchProviders(): Record { - return SEARCH_PROVIDERS; -} - -export const SEARCH_CREDENTIAL_FALLBACKS: Record = { "perplexity-search": "perplexity", +/** + * Credential fallback mapping — search providers that can reuse credentials + * from a related provider (e.g., perplexity-search uses the same API key as perplexity chat). + */ +export const SEARCH_CREDENTIAL_FALLBACKS: Record = { + "perplexity-search": "perplexity", "ollama-search": "ollama-cloud", "zai-search": "zai", }; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 4a0da6d253..b876bb0b6e 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -24,11 +24,7 @@ interface VideoProvider { models: VideoModel[]; } -let _VIDEO_PROVIDERS: Record | null = null; - -function getOrCreateVideoProviders(): Record { - if (!_VIDEO_PROVIDERS) { - _VIDEO_PROVIDERS = { +export const VIDEO_PROVIDERS: Record = { kie: { id: "kie", baseUrl: "https://api.kie.ai", @@ -152,59 +148,25 @@ function getOrCreateVideoProviders(): Record { format: "runwayml", models: RUNWAYML_SUPPORTED_VIDEO_MODELS, }, - }; -} - return _VIDEO_PROVIDERS; -} - -export const VIDEO_PROVIDERS: Record = new Proxy({} as Record, { - get(target, key: string) { - if (key in target) { - return target[key]; - } - return getOrCreateVideoProviders()[key]; - }, - set(target, key: string, value) { - target[key] = value; - getOrCreateVideoProviders()[key] = value; - return true; - }, - deleteProperty(target, key: string) { - delete target[key]; - delete getOrCreateVideoProviders()[key]; - return true; - }, - ownKeys(target) { - const targetKeys = Reflect.ownKeys(target); - const registryKeys = Reflect.ownKeys(getOrCreateVideoProviders()); - return Array.from(new Set([...targetKeys, ...registryKeys])); - }, - has(target, key) { - return key in target || key in getOrCreateVideoProviders(); - }, - getOwnPropertyDescriptor(target, key) { - if (key in target) { - return Reflect.getOwnPropertyDescriptor(target, key); - } - if (key in getOrCreateVideoProviders()) { - return { configurable: true, enumerable: true, value: getOrCreateVideoProviders()[key as string] }; - } - return undefined; - }, -}); - -export function getVideoProviders(): Record { - return VIDEO_PROVIDERS; -} +}; +/** + * Get video provider config by ID + */ export function getVideoProvider(providerId: string): VideoProvider | null { return VIDEO_PROVIDERS[providerId] || null; } +/** + * Parse video model string (format: "provider/model" or just "model") + */ export function parseVideoModel(modelStr: string | null) { return parseModelFromRegistry(modelStr, VIDEO_PROVIDERS); } +/** + * Get all video models as a flat list + */ export function getAllVideoModels() { return getAllModelsFromRegistry(VIDEO_PROVIDERS); -} \ No newline at end of file +} diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 084db6a919..82bd16c66f 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,6 +1,8 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; +import type { PoolConfig } from "../services/sessionPool/types.ts"; +import type { Session } from "../services/sessionPool/session.ts"; import { getRotatingApiKey, getValidApiKey, @@ -21,8 +23,9 @@ import { modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; +import { cloakThirdPartyToolNames, remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; +import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts"; import { @@ -320,6 +323,10 @@ export class BaseExecutor { provider: string; config: ProviderConfig; + // Session pool support — subclasses can set poolConfig to opt in + protected poolConfig?: PoolConfig; + private _pool: import("../services/sessionPool/sessionPool.ts").SessionPool | null = null; + constructor(provider: string, config: ProviderConfig) { this.provider = provider; this.config = config; @@ -329,6 +336,24 @@ export class BaseExecutor { return this.provider; } + protected getPool(): import("../services/sessionPool/sessionPool.ts").SessionPool | null { + if (!this.poolConfig) return null; + if (!this._pool) { + const { SessionPool } = require("../services/sessionPool/sessionPool.ts"); + const { PoolRegistry } = require("../services/sessionPool/poolRegistry.ts"); + const pool = new SessionPool(this.provider, this.poolConfig); + pool.warmUp(this.poolConfig.minSessions).catch(() => {}); + PoolRegistry.register(this.provider, pool); + this._pool = pool; + } + return this._pool; + } + + protected buildPoolHeaders(session: Session | null): Record { + if (!session) return {}; + return session.buildHeaders(); + } + getBaseUrls() { return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []); } @@ -775,6 +800,13 @@ export class BaseExecutor { stripProxyToolPrefix(tb); remapToolNamesInRequest(tb); + // Cloak third-party tool names + sanitize invalid tool schemas so + // Anthropic does not refuse native Claude OAuth traffic with a + // misleading "out of extra usage" placeholder. See Spec E. + cloakThirdPartyToolNames(tb); + if (Array.isArray(tb.tools)) { + tb.tools = sanitizeClaudeToolSchemas(tb.tools); + } obfuscateInBody(tb); // NOTE (issue #2260): This is the native `claude` provider OAuth path. diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index ef78832a90..dc3140b1b7 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -12,7 +12,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; // ---------- Versions ------------------------------------------------------ -export const CLAUDE_CODE_VERSION = "2.1.146"; +export const CLAUDE_CODE_VERSION = "2.1.158"; /** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0"; @@ -342,9 +342,12 @@ export function selectBetaFlags( const flags: string[] = []; if (isFullAgent) flags.push("claude-code-20250219"); flags.push("oauth-2025-04-20"); - if (isContext1m) flags.push("context-1m-2025-08-07"); + if (isContext1m) { + flags.push("context-1m-2025-08-07", "mid-conversation-system-2026-04-07"); + } flags.push( "interleaved-thinking-2025-05-14", + "redact-thinking-2026-02-12", "thinking-token-count-2026-05-13", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05" @@ -357,7 +360,7 @@ export function selectBetaFlags( flags.push("extended-cache-ttl-2025-04-11", "cache-diagnosis-2026-04-07"); } if (isHeavyAgent) { - flags.push("afk-mode-2026-01-31", "advanced-tool-use-2025-11-20", "effort-2025-11-24"); + flags.push("advanced-tool-use-2025-11-20", "effort-2025-11-24"); } return flags.join(","); } diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index eeb238b622..c4b2047f2a 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -22,6 +22,8 @@ import { type ProviderCredentials, } from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts"; +import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; @@ -335,9 +337,38 @@ export class CliproxyapiExecutor extends BaseExecutor { // uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to // rewrite tool_use.name back to the client's original namespace on // the response side. Capy sees mcp_call back in tool_use blocks. - const toolNameMap = applyMcpToolNameRewrite(transformed); + // Sanitize invalid tool input_schemas (truncation placeholders such as + // `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required) + // that Anthropic rejects with `tools.N.custom.input_schema: JSON schema is + // invalid` — surfaced as the same misleading "out of extra usage" 400. + if (Array.isArray(transformed.tools)) { + transformed.tools = sanitizeClaudeToolSchemas(transformed.tools) as unknown[]; + } + + // Cloak third-party / blacklisted tool names (e.g. `mixture_of_agents`, or + // a large enough set of recognizable snake_case agent tools) that Anthropic + // fingerprints and refuses with the same placeholder. The `mcp_*` reserved + // namespace is deferred to applyMcpToolNameRewrite below (its bisected + // `Mcp_X` form) so the two reverse maps stay disjoint and single-hop. + const cloakMap = cloakThirdPartyToolNames(transformed, { + skip: (name) => MCP_RESERVED_PREFIX_RE.test(name), + }); + + const mcpMap = applyMcpToolNameRewrite(transformed); + + const toolNameMap = new Map(cloakMap); + for (const [alias, original] of mcpMap) { + toolNameMap.set(alias, original); + } if (toolNameMap.size > 0) { - transformed._toolNameMap = toolNameMap; + // Non-enumerable: chatCore reads this for response-side tool-name + // restoration; the wire body must never carry it (also stripped in execute()). + Object.defineProperty(transformed, "_toolNameMap", { + value: toolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); } } diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index f7b5ba3192..e27384a072 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -379,6 +379,46 @@ function stripStoredItemReferences(body: Record): void { } } +function repairMissingCodexFunctionCallOutputs(body: Record): void { + if (!Array.isArray(body.input)) return; + + const existingOutputIds = new Set(); + for (const item of body.input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if (record.type !== "function_call_output") continue; + if (typeof record.call_id === "string" && record.call_id.trim()) { + existingOutputIds.add(record.call_id.trim()); + } + } + + const repaired: unknown[] = []; + let insertedCount = 0; + for (const item of body.input) { + repaired.push(item); + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if (record.type !== "function_call") continue; + const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; + if (!callId || existingOutputIds.has(callId)) continue; + + repaired.push({ + type: "function_call_output", + call_id: callId, + output: "", + }); + existingOutputIds.add(callId); + insertedCount++; + } + + if (insertedCount > 0) { + body.input = repaired; + console.debug( + `[Codex] repairMissingCodexFunctionCallOutputs: inserted ${insertedCount} empty function_call_output item(s)` + ); + } +} + // 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 @@ -396,7 +436,20 @@ const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ "local_shell", ]); -function normalizeCodexTools(body: Record): void { +// #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 } +): void { if (!Array.isArray(body.tools)) return; const validToolNames = new Set(); @@ -430,6 +483,11 @@ function normalizeCodexTools(body: Record): void { 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}`); @@ -1138,6 +1196,7 @@ export class CodexExecutor extends BaseExecutor { if (Array.isArray(body.input)) { body.input = sanitizeResponsesInputItems(body.input, false); } + repairMissingCodexFunctionCallOutputs(body); // ── Cache-aware system prompt handling (both paths) ── // @@ -1201,7 +1260,9 @@ export class CodexExecutor extends BaseExecutor { // Codex Responses only supports function tools with non-empty names. // Cursor may include custom tools (e.g. ApplyPatch) that work locally but are // invalid upstream, and translation bugs can leave orphaned/empty tool_choice names. - normalizeCodexTools(body); + normalizeCodexTools(body, { + dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData), + }); // Strip stored response item references (rs_, resp_, msg_ IDs) from input. // The /codex/responses endpoint does not persist responses even with store=true, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8ff002ff23..3dd2ac39af 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -29,6 +29,8 @@ import { buildOciChatUrl } from "../config/oci.ts"; import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts"; import { buildMaritalkChatUrl } from "../config/maritalk.ts"; +import type { PoolConfig } from "../services/sessionPool/types.ts"; + function normalizeBaseUrl(baseUrl) { return (baseUrl || "").trim().replace(/\/$/, ""); } @@ -103,6 +105,10 @@ function normalizeOpenAIChatUrl(baseUrl) { export class DefaultExecutor extends BaseExecutor { constructor(provider) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); + const registryEntry = getRegistryEntry(provider); + if (registryEntry?.poolConfig) { + this.poolConfig = registryEntry.poolConfig as PoolConfig; + } } buildUrl(model, stream, urlIndex = 0, credentials = null) { @@ -195,6 +201,10 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return buildMaritalkChatUrl(baseUrl); } + case "siliconflow": { + const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + return normalizeOpenAIChatUrl(baseUrl); + } case "lm-studio": case "modal": case "reka": diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 9dd1c091a1..71a24034ed 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -26,6 +26,16 @@ const FAKE_HEADERS: Record = { * VQD tokens are per-request; no caching or cleanup needed. */ export class DuckDuckGoWebExecutor extends BaseExecutor { + protected poolConfig = { + minSessions: 2, + maxSessions: 5, + cooldownBase: 1000, + cooldownMax: 10000, + cooldownJitter: 500, + requestTimeout: 30000, + requestJitter: 50, + }; + constructor() { super("duckduckgo-web", { baseUrl: DUCKDUCKGO_BASE }); } diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d5428950ac..bce527ce08 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -5,6 +5,7 @@ import { QoderExecutor } from "./qoder.ts"; import { KiroExecutor } from "./kiro.ts"; import { CodexExecutor } from "./codex.ts"; import { CursorExecutor } from "./cursor.ts"; +import { TraeExecutor } from "./trae.ts"; import { DefaultExecutor } from "./default.ts"; import { BedrockExecutor } from "./bedrock.ts"; import { GlmExecutor } from "./glm.ts"; @@ -45,9 +46,11 @@ import { VeniceWebExecutor } from "./venice-web.ts"; import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; +import { QwenWebExecutor } from "./qwen-web.ts"; const executors = { antigravity: new AntigravityExecutor(), + agy: new AntigravityExecutor(), "gemini-cli": new GeminiCLIExecutor(), github: new GithubExecutor(), qoder: new QoderExecutor(), @@ -56,6 +59,7 @@ const executors = { bedrock: new BedrockExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), + trae: new TraeExecutor(), glm: new GlmExecutor("glm"), "glm-cn": new GlmExecutor("glm-cn"), glmt: new GlmExecutor("glmt"), @@ -108,7 +112,7 @@ const executors = { "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias "duckduckgo-web": new DuckDuckGoWebExecutor(), - "ddgw": new DuckDuckGoWebExecutor(), // Alias + ddgw: new DuckDuckGoWebExecutor(), // Alias "t3-web": new T3ChatWebExecutor(), t3chat: new T3ChatWebExecutor(), // Alias "inner-ai": new InnerAiExecutor(), @@ -127,6 +131,8 @@ const executors = { kimi: new KimiWebExecutor(), // Alias "doubao-web": new DoubaoWebExecutor(), db: new DoubaoWebExecutor(), // Alias + "qwen-web": new QwenWebExecutor(), + qw: new QwenWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -149,6 +155,7 @@ export { QoderExecutor } from "./qoder.ts"; export { KiroExecutor } from "./kiro.ts"; export { CodexExecutor } from "./codex.ts"; export { CursorExecutor } from "./cursor.ts"; +export { TraeExecutor } from "./trae.ts"; export { DefaultExecutor } from "./default.ts"; export { BedrockExecutor } from "./bedrock.ts"; export { GlmExecutor } from "./glm.ts"; @@ -182,3 +189,4 @@ export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-ref export { AdaptaWebExecutor } from "./adapta-web.ts"; export { T3ChatWebExecutor } from "./t3-chat-web.ts"; export { InnerAiExecutor } from "./inner-ai.ts"; +export { QwenWebExecutor } from "./qwen-web.ts"; diff --git a/open-sse/executors/pollinations.ts b/open-sse/executors/pollinations.ts index 285557b910..eae763e458 100644 --- a/open-sse/executors/pollinations.ts +++ b/open-sse/executors/pollinations.ts @@ -1,16 +1,8 @@ import { BaseExecutor } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { SessionPool, PoolRegistry } from "../services/sessionPool/index.ts"; +import type { ExecuteInput } from "./base.ts"; -/** - * PollinationsExecutor — OpenAI-compatible Pollinations text endpoint. - * - * Pollinations currently exposes a public endpoint and an optional key-backed tier. - * OmniRoute sends the bearer token when configured, but no auth header is required - * for the anonymous endpoint. - * - * Endpoint: https://text.pollinations.ai/openai/chat/completions - * Docs: https://pollinations.ai/docs - */ export class PollinationsExecutor extends BaseExecutor { constructor() { super("pollinations", PROVIDERS["pollinations"] || { format: "openai" }); @@ -19,7 +11,7 @@ export class PollinationsExecutor extends BaseExecutor { buildUrl(_model: string, _stream: boolean, urlIndex = 0, _credentials = null): string { const baseUrls = this.getBaseUrls(); return ( - baseUrls[urlIndex] || baseUrls[0] || "https://text.pollinations.ai/openai/chat/completions" + baseUrls[urlIndex] || baseUrls[0] || "https://gen.pollinations.ai/v1/chat/completions" ); } @@ -49,6 +41,54 @@ export class PollinationsExecutor extends BaseExecutor { } return body; } + + async execute(input: ExecuteInput) { + const isAnonymous = !input.credentials?.apiKey && !input.credentials?.accessToken; + + if (!isAnonymous) { + return super.execute(input); + } + + const pool = this.getPool(); + const session = pool ? pool.acquire() : null; + + if (session) { + const fpHeaders = session.buildHeaders(); + input.upstreamExtraHeaders = { + ...fpHeaders, + ...input.upstreamExtraHeaders, + }; + } + + let result; + try { + result = await super.execute(input); + } catch (err) { + if (session && pool) { + pool.reportCooldown(session); + session.release(); + } + throw err; + } + + if (session && pool) { + try { + const status = result.response.status; + if (status === 429) { + pool.reportCooldown(session); + } else if (status >= 500) { + pool.reportDead(session); + } else { + pool.reportSuccess(session); + pool.totalRequests++; + } + } finally { + session.release(); + } + } + + return result; + } } export default PollinationsExecutor; diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts new file mode 100644 index 0000000000..66f8aec0db --- /dev/null +++ b/open-sse/executors/qwen-web.ts @@ -0,0 +1,159 @@ +/** + * QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai + * + * Routes requests through Qwen's consumer chat API. + * Chinese market provider with strong vision, coding, and reasoning models. + * + * Auth: Token from chat.qwen.ai Local Storage or tongyi_sso_ticket cookie + * Endpoint: POST https://chat.qwen.ai/api/chat/completions + * Format: OpenAI-compatible + */ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; + +const BASE_URL = "https://chat.qwen.ai"; +const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +export class QwenWebExecutor extends BaseExecutor { + constructor() { + super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL }); + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + const rawToken = String(credentials?.apiKey ?? credentials?.accessToken ?? "").trim(); + + const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; + const modelId = (bodyObj.model as string) || "qwen-plus"; + + const reqBody = { + messages: messages.map((m) => ({ role: m.role, content: m.content })), + model: modelId, + stream: wantStream, + max_tokens: (bodyObj.max_tokens as number) || 4096, + }; + + const reqHeaders: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: wantStream ? "text/event-stream" : "application/json", + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + }; + if (rawToken) { + reqHeaders["Authorization"] = `Bearer ${rawToken}`; + } + + let upstream: Response; + try { + upstream = await fetch(CHAT_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(reqBody), + signal, + }); + } catch (err) { + return makeErrorResult( + 502, + `Qwen fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHAT_URL + ); + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + if (upstream.status === 401) { + return makeErrorResult( + 401, + "Qwen authentication failed. Your token may have expired. " + + "Get a fresh token from chat.qwen.ai (DevTools → Application → Local Storage → token)", + body, + CHAT_URL + ); + } + return makeErrorResult(upstream.status, `Qwen error: ${errText}`, body, CHAT_URL); + } + + if (!wantStream) { + const data = (await upstream.json()) as Record; + const content = + (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || + (data?.content as string) || + ""; + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }), + { headers: { "Content-Type": "application/json" } } + ), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } + + // Streaming + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const stream = new ReadableStream({ + async start(controller) { + const reader = upstream.body?.getReader(); + if (!reader) { controller.close(); return; } + let buffer = ""; + try { + while (true) { + const { done, value } = 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) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } + try { + const parsed = JSON.parse(data); + const text = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.text || ""; + if (text) { + const chunk = { + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + } catch { + /* skip unparseable chunks */ + } + } + } + } catch (err) { + if (!signal?.aborted) controller.error(err); + } finally { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return { + response: new Response(stream, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } +} diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts new file mode 100644 index 0000000000..47b306fc93 --- /dev/null +++ b/open-sse/executors/trae.ts @@ -0,0 +1,481 @@ +/** + * TraeExecutor — talks to Trae's remote agent API (solo_agent_remote). + * + * Flow (reverse-engineered from solo.trae.ai web client): + * 1. POST {base}/chat_sessions → { data: { chat_session_id, message_id } } + * 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id} + * → text/event-stream. Assistant text streams in `plan_item` events under + * the `thought` field (cumulative per plan-item id). `token_usage` carries + * usage; `done` ends the turn; `error` carries upstream errors. + * + * Auth: header `Authorization: Cloud-IDE-JWT ` (RS256, ~14-day lifetime). + * The JWT is stored as credentials.accessToken; identity fields (web_id, + * biz_user_id, user_unique_id, scope, tenant, region) live in providerSpecificData. + * + * Model selection: model="auto" → server picks; otherwise model is the upstream + * `name` from GET {base}/models (e.g. gpt-5.2, gemini-3.1-pro, kimi-k2.5). + */ + +import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +type JsonRecord = Record; +type ChatMessage = { role?: string; content?: unknown }; + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); + +function flattenQuery(messages: ChatMessage[]): string { + const parts: string[] = []; + for (const m of messages) { + let content = ""; + if (typeof m.content === "string") content = m.content; + else if (Array.isArray(m.content)) { + content = m.content + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String((p as JsonRecord).text ?? ""); + return ""; + }) + .join(""); + } + if (m.role === "system") parts.push(`[System]\n${content}`); + else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`); + else parts.push(content); + } + const text = parts.join("\n\n"); + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: text } }]); +} + +export class TraeExecutor extends BaseExecutor { + constructor() { + super("trae", PROVIDERS["trae"]); + } + + private base(): string { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); + } + + buildHeaders(credentials): Record { + const token = (credentials.accessToken as string) || ""; + const psd = (credentials.providerSpecificData as JsonRecord) || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": (psd.appLanguage as string) || "en", + "x-user-region": (psd.userRegion as string) || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + }; + } + + /** + * SOLO exposes two session modes (the toggle on solo.trae.ai): + * - "code" (default): full model picker — `auto` plus named models + * (gpt-5.4, kimi-k2.5, gemini-3.1-pro, …). + * - "work": a single, faster "auto" agent with no model picker. + * We surface "work" as its own model id (`trae/work`) so callers can opt into + * the fast lane; any other model id runs in "code" mode. "work" forces the + * auto strategy with an empty model_name, since it has no model selection. + */ + private resolveMode(model: string): { + mode: "code" | "work"; + strategy: "auto" | "manual"; + modelName: string; + } { + const m = (model || "").trim().toLowerCase(); + if (m === "work" || m === "auto-work" || m === "solo-work") { + return { mode: "work", strategy: "auto", modelName: "" }; + } + const auto = !m || m === "auto"; + return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model }; + } + + private commonParams(psd: JsonRecord, mode: "code" | "work", sessionId?: string): string { + const cp: JsonRecord = { + language: "en-us", + app_language: (psd.appLanguage as string) || "en", + quality: "stable", + app_version: (psd.appVersion as string) || "1.0.0.1229", + web_id: (psd.webId as string) || "", + user_identity: (psd.userIdentity as string) || "Free", + is_freshman: "0", + biz_user_id: (psd.bizUserId as string) || "", + user_unique_id: (psd.userUniqueId as string) || "", + scope: (psd.scope as string) || "marscode-us", + tenant: (psd.tenant as string) || "marscode", + region: (psd.region as string) || "US-East", + aiRegion: (psd.aiRegion as string) || (psd.region as string) || "US-East", + is_privacy_mode: 0, + privacy_mode: "off", + solo_chat_mode: mode, + }; + if (sessionId) cp.biz_session_id = sessionId; + return JSON.stringify(cp); + } + + /** POST /chat_sessions — creates a session and submits the first turn. */ + private async createSession( + headers: Record, + query: string, + model: string, + psd: JsonRecord, + signal?: AbortSignal | null + ): Promise<{ sessionId: string; messageId: string }> { + const { mode, strategy, modelName } = this.resolveMode(model); + const body = { + mode, + environment_id: "default", + initial_message: { + chat_session_id: "", + content: [], + query, + model_name: modelName, + agent_type: "solo_agent_remote", + model_selection_strategy: strategy, + common_params: this.commonParams(psd, mode), + }, + env: "remote", + auto_create_project: false, + origin: "web", + }; + const res = await fetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: signal || undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`[${res.status}] ${text}`); + const json = JSON.parse(text); + if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); + return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; + } + + /** + * GET /events SSE → invoke onEvent(eventType, dataObj) per frame. + * Resolves when `done`/`error` arrives, the stream ends, or timeout fires. + */ + private async streamEvents( + headers: Record, + sessionId: string, + replyTo: string, + onEvent: (ev: string | null, data: JsonRecord) => boolean, + signal?: AbortSignal | null + ): Promise { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + // If the caller's signal is already aborted, abort upfront so we don't open + // a network request the consumer no longer wants. + if (signal?.aborted) ctrl.abort(); + const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + try { + const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal }); + if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let ev: string | null = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl: number; + // SSE frames are separated by lines; process complete lines only. + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) { + const payload = line.slice(5).trim(); + let data: JsonRecord; + try { + data = JSON.parse(payload); + } catch { + data = { _raw: payload }; + } + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } + } else if (line === "") ev = null; + } + } + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } + } + + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) { + const headers = this.buildHeaders(credentials as JsonRecord); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record | null); + const psd = ((credentials as JsonRecord).providerSpecificData as JsonRecord) || {}; + const reqBody = body as { messages?: ChatMessage[] }; + const query = flattenQuery(reqBody.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status: number, message: string) => + new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session: { sessionId: string; messageId: string }; + try { + session = await this.createSession( + headers, + query, + model as string, + psd, + signal as AbortSignal + ); + } catch (err) { + return { + response: errResponse(502, err instanceof Error ? err.message : String(err)), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order: string[] = []; + const thoughts: Record = {}; + let sent = 0; + let usage: JsonRecord | null = null; + let errorEvent: JsonRecord | null = null; + const renderNewText = (data: JsonRecord): string => { + const pid = data.id as string | undefined; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = (data.thought as string) || ""; + if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t; + const full = order.map((i) => thoughts[i]).join(""); + const piece = full.slice(sent); + sent = full.length; + return piece; + }; + + if (stream !== false) { + const enc = new TextEncoder(); + const sse = new ReadableStream({ + start: async (controller) => { + const emit = (obj: JsonRecord) => + controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + let roleEmitted = false; + try { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + roleEmitted = true; + await this.streamEvents( + headers, + session.sessionId, + session.messageId, + (ev, data) => { + if (ev === "error") { + errorEvent = data; + return true; + } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") { + const piece = renderNewText(data); + if (piece) + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: piece }, finish_reason: null }], + }); + } + return ev === "done"; + }, + signal as AbortSignal + ); + void roleEmitted; + if (errorEvent) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + error: { + message: `trae ${errorEvent.code}: ${errorEvent.message}`, + type: "api_error", + }, + }); + } else { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + if (usage) + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + usage: { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }, + }); + } + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + return { + response: new Response(sse, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + try { + await this.streamEvents( + headers, + session.sessionId, + session.messageId, + (ev, data) => { + if (ev === "error") { + errorEvent = data; + return true; + } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") renderNewText(data); + return ev === "done"; + }, + signal as AbortSignal + ); + } catch (err) { + return { + response: errResponse(502, err instanceof Error ? err.message : String(err)), + url: this.base(), + headers, + transformedBody: body, + }; + } + if (errorEvent) { + return { + response: errResponse(502, `trae ${errorEvent.code}: ${errorEvent.message}`), + url: this.base(), + headers, + transformedBody: body, + }; + } + const content = order.map((i) => thoughts[i]).join(""); + const out: JsonRecord = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }; + if (usage) + out.usage = { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }; + return { + response: new Response(JSON.stringify(out), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + /** + * Headless refresh of the 14-day Cloud-IDE-JWT using the long-lived (~7 month) + * RefreshToken captured during /authorize. Mirrors the desktop client's call to + * POST {apiHost}/cloudide/api/v3/trae/oauth/ExchangeToken + * { ClientID, RefreshToken, ClientSecret: "-", UserID: "" } + * The response uses the same envelope as GetUserToken: + * { ResponseMetadata: { Error?: { Code, Message } }, Result: { Token, RefreshToken, + * TokenExpireAt, RefreshExpireAt, TokenExpireDuration, UserID, TenantID } } + * On Error.Code === "RefreshTokenInvalid" the caller must re-authorize via + * the browser flow — we throw so the connection is marked unusable. + */ + async refreshCredentials(credentials) { + const psd = (credentials?.providerSpecificData as JsonRecord) || {}; + const refreshToken = credentials?.refreshToken as string | undefined; + if (!refreshToken) return null; + const host = ((psd.host as string) || "https://api-us-east.trae.ai").replace(/\/$/, ""); + const clientId = (psd.clientId as string) || "en1oxy7wnw8j9n"; + const url = `${host}/cloudide/api/v3/trae/oauth/ExchangeToken`; + const body = { ClientID: clientId, RefreshToken: refreshToken, ClientSecret: "-", UserID: "" }; + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Trae ExchangeToken HTTP ${res.status}: ${text.slice(0, 200)}`); + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Trae ExchangeToken: response was not JSON"); + } + const errCode = parsed?.ResponseMetadata?.Error?.Code; + if (errCode) { + // Surface invalid-refresh to the caller — BaseExecutor.execute swallows the + // refresh exception, but the next request will hit 401 and trigger fallback; + // we also leave the (stale) accessToken in place so observability shows why. + throw new Error(`Trae ExchangeToken error: ${errCode}`); + } + const result = parsed?.Result; + if (!result?.Token) { + throw new Error("Trae ExchangeToken: response missing Result.Token"); + } + return { + accessToken: result.Token as string, + refreshToken: (result.RefreshToken as string) || refreshToken, + expiresAt: result.TokenExpireAt + ? new Date(Number(result.TokenExpireAt)).toISOString() + : undefined, + }; + } +} + +export default TraeExecutor; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 109930bf86..a3209c41fc 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -59,7 +59,8 @@ import { PROVIDER_ERROR_TYPES, isEmptyContentResponse, } from "../services/errorClassifier.ts"; -import { updateProviderConnection } from "@/lib/db/providers"; +import { updateProviderConnection, getProviderConnectionById } from "@/lib/db/providers"; +import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { recordKeyFailure, recordKeySuccess, @@ -68,7 +69,7 @@ import { connectionHasExtraKeys, type KeyHealth, } from "../services/apiKeyRotator.ts"; -import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs"; + import { getCallLogPipelineCaptureStreamChunks, getChatLogTextLimit, @@ -277,13 +278,7 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { return result; } -function isSmallEnoughForSemanticCache(value: unknown): boolean { - try { - return JSON.stringify(value).length <= 256 * 1024; - } catch { - return false; - } -} +import { estimateSizeFast, isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; function extractMemoryTextFromResponse( response: Record | null | undefined @@ -1500,6 +1495,15 @@ export async function handleChatCore({ ? ((modelInfo as { apiFormat?: string }).apiFormat as string) : undefined : undefined; + // #2905: per-model wire-format override for custom models, injected by + // getModelInfo. Custom models are not in the static registry, so + // getModelTargetFormat() can't see this — use it before the provider default. + const customModelTargetFormat: string | undefined = + modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo + ? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string" + ? ((modelInfo as { targetFormat?: string }).targetFormat as string) + : undefined + : undefined; const requestedModel = typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model; const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData); @@ -1518,9 +1522,19 @@ export async function handleChatCore({ comboName: comboName || undefined, }); }); + const traceEnabled = + process.env.OMNIRROUTE_TRACE === "true" || process.env.DEBUG === "true"; const trace = (label: string, extra?: Record) => { + if (!traceEnabled) return; const elapsed = Date.now() - startTime; - const suffix = extra ? ` ${JSON.stringify(extra)}` : ""; + let suffix = ""; + if (extra) { + try { + suffix = ` ${JSON.stringify(extra)}`; + } catch { + suffix = " [unserializable]"; + } + } log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); }; let tokensCompressed: number | null = null; @@ -1871,7 +1885,9 @@ export async function handleChatCore({ const targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || getTargetFormat(provider, credentials?.providerSpecificData); + : modelTargetFormat || + customModelTargetFormat || + getTargetFormat(provider, credentials?.providerSpecificData); const initialProviderRequest = body && typeof body === "object" && !Array.isArray(body) @@ -1912,7 +1928,13 @@ export async function handleChatCore({ ); } const noLogEnabled = apiKeyInfo?.noLog === true; - const detailedLoggingEnabled = !noLogEnabled && (await isDetailedLoggingEnabled()); + // Consolidate settings reads — fetch once, reuse throughout the request + const settings = cachedSettings ?? (await getCachedSettings()); + const detailedLoggingEnabled = + !noLogEnabled && + (settings.call_log_pipeline_enabled === true || + settings.call_log_pipeline_enabled === "1" || + settings.call_log_pipeline_enabled === "true"); const capturePipelineStreamChunks = detailedLoggingEnabled && getCallLogPipelineCaptureStreamChunks(); const skillRequestId = generateRequestId(); @@ -2105,6 +2127,12 @@ export async function handleChatCore({ clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept") : clientRawRequest?.headers?.["accept"] || clientRawRequest?.headers?.["Accept"]; + const streamUserAgent = [ + typeof userAgent === "string" ? userAgent : "", + getHeaderValueCaseInsensitive(clientRawRequest?.headers ?? null, "user-agent") || "", + ] + .filter(Boolean) + .join(" "); const explicitStreamAlias = resolveExplicitStreamAlias(body); @@ -2130,8 +2158,14 @@ export async function handleChatCore({ const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) ? false - : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat); - const settings = cachedSettings ?? (await getCachedSettings()); + : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, { + userAgent: streamUserAgent, + streamDefaultMode: apiKeyInfo?.streamDefaultMode, + }); + // `settings` is already consolidated once near the top of handleChatCore + // (the "fetch once, reuse" const). A second `const settings` here was a + // duplicate same-scope declaration that broke the esbuild/tsx transform + // ("settings has already been declared") and the production build. Reuse it. credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings, { model: requestedModel, body: body && typeof body === "object" ? (body as Record) : null, @@ -2426,7 +2460,7 @@ export async function handleChatCore({ let cavemanOutputModeIntensity: string | null = null; let preCompressionBody: typeof body | null = null; if (body && Array.isArray(allMessages) && allMessages.length > 0) { - let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); + let estimatedTokens = estimateTokens(allMessages); let promptCompressionEnabled = false; let compressionSettings: CompressionConfig | null = null; @@ -2657,7 +2691,7 @@ export async function handleChatCore({ body = outputMode.body as typeof body; cavemanOutputModeApplied = true; cavemanOutputModeIntensity = config.cavemanOutputMode.intensity; - estimatedTokens = estimateTokens(JSON.stringify(body?.messages ?? body?.input ?? [])); + estimatedTokens = estimateTokens(body?.messages ?? body?.input ?? []); log?.debug?.("COMPRESSION", "Caveman output mode instruction applied"); } else if (outputMode.skippedReason && outputMode.skippedReason !== "disabled") { log?.debug?.("COMPRESSION", `Caveman output mode skipped: ${outputMode.skippedReason}`); @@ -2867,7 +2901,7 @@ export async function handleChatCore({ const COMPRESSION_THRESHOLD = 0.7; let reservedTokens = 0; if (Array.isArray(body.tools)) { - reservedTokens = estimateTokens(JSON.stringify(body.tools)); + reservedTokens = estimateTokens(body.tools); } const threshold = Math.max( 1, @@ -4109,7 +4143,7 @@ export async function handleChatCore({ (translatedBody.conversationState?.history?.length ?? 0) + (translatedBody.conversationState?.currentMessage ? 1 : 0) || 0; - log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); + log?.debug?.("REQUEST", `${provider?.toUpperCase()} | ${model} | ${msgCount} msgs`); // ── Tier 2: Authoritative per-model/provider token-limit check (provider now resolved) ── if (apiKeyInfo?.id) { @@ -4323,6 +4357,11 @@ export async function handleChatCore({ // inside getAccessToken, we still need to do the credentials mutation + user // callback after refreshCredentials returns. The `persistFnRan` flag tracks // which path executed so we don't double-fire (race-prone) or skip (regression). + // Front 3: remember the refresh_token we are about to present so that, if the + // refresh fails as unrecoverable, we can tell a genuine death apart from a + // stale-token reuse that a concurrent/sibling refresh already rotated past. + const attemptedRefreshToken = + typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null; let persistFnRan = false; const persistFn = onCredentialsRefreshed ? async (refreshResult: Record) => { @@ -4345,7 +4384,7 @@ export async function handleChatCore({ }; if (newCredentials?.accessToken || newCredentials?.copilotToken) { - log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); + log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`); // Fall back to post-mutex mutation only for executors that don't route // through getAccessToken (and therefore never fire onPersist). For @@ -4399,13 +4438,35 @@ export async function handleChatCore({ // than the original 401 alone. Surface at error level with sanitization. log?.error?.( "TOKEN", - `${provider.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` + `${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}` ); } } else { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`); if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) { - await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + // Front 3 (reuse-race tolerance): before deactivating, re-read the DB. + // If a sibling/concurrent refresh already rotated this connection's + // refresh_token (common for Codex/OpenAI under one shared Auth0 client), + // the failure we saw was a stale-token reuse — the account is healthy + // with the newer token, so keep it active instead of killing it. + let alreadyRotated = false; + if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) { + try { + const latest = await getProviderConnectionById(connectionId); + if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) { + alreadyRotated = true; + log?.warn?.( + "TOKEN", + `${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active` + ); + } + } catch { + // DB read failed — fall through to the safe default (deactivate). + } + } + if (!alreadyRotated) { + await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + } } } } @@ -5053,8 +5114,10 @@ export async function handleChatCore({ // Save structured call log with full payloads const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); if (usage && typeof usage === "object") { - const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; - console.log(`${COLORS.green}${msg}${COLORS.reset}`); + if (traceEnabled) { + const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider?.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; + console.log(`${COLORS.green}${msg}${COLORS.reset}`); + } saveRequestUsage({ provider: provider || "unknown", diff --git a/open-sse/mcp-server/scopeEnforcement.ts b/open-sse/mcp-server/scopeEnforcement.ts index 4c622d1e2f..9d0477c16c 100644 --- a/open-sse/mcp-server/scopeEnforcement.ts +++ b/open-sse/mcp-server/scopeEnforcement.ts @@ -99,26 +99,28 @@ export function resolveCallerScopeContext( export function evaluateToolScopes( toolName: string, callerScopes: readonly string[], - enforceScopes: boolean + enforceScopes: boolean, + inlineScopes?: readonly string[] ): ScopeCheckResult { - const toolDef = MCP_TOOL_MAP[toolName]; - if (!toolDef) { + const provided = normalizeScopeList(callerScopes); + + if (!enforceScopes) { + return { allowed: true, required: [], provided, missing: [] }; + } + + const toolScopes = inlineScopes ?? MCP_TOOL_MAP[toolName]?.scopes; + const required = Array.isArray(toolScopes) ? Array.from(toolScopes) : []; + + if (required.length === 0) { return { allowed: false, required: [], - provided: Array.from(callerScopes), + provided, missing: [], reason: "tool_definition_missing", }; } - const required = Array.isArray(toolDef.scopes) ? Array.from(toolDef.scopes) : []; - const provided = normalizeScopeList(callerScopes); - - if (!enforceScopes || required.length === 0) { - return { allowed: true, required, provided, missing: [] }; - } - const missing = required.filter( (requiredScope) => !provided.some((grantedScope) => scopeMatches(grantedScope, requiredScope)) ); diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 25d7a93394..dc3d23d835 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -79,6 +79,7 @@ import { agentSkillTools } from "./tools/agentSkillTools.ts"; import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; +import { notionTools } from "./tools/notionTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts"; import { @@ -106,7 +107,8 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(skillTools).length + Object.keys(agentSkillTools).length + gamificationTools.length + - pluginTools.length; + pluginTools.length + + notionTools.length; type JsonRecord = Record; @@ -205,11 +207,12 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< function withScopeEnforcement( toolName: string, - handler: (args: unknown, extra?: McpToolExtraLike) => Promise + handler: (args: unknown, extra?: McpToolExtraLike) => Promise, + toolScopes?: readonly string[] ) { return async (args: unknown, extra?: McpToolExtraLike): Promise => { const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); - const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES); + const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes); if (!scopeCheck.allowed) { const missingScopes = scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; @@ -962,7 +965,7 @@ export function createMcpServer(): McpServer { ); // ── Memory Tools ────────────────────────────── - Object.values(memoryTools).forEach((toolDef) => { + Object.values(memoryTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -970,22 +973,25 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); // ── Skill Tools ────────────────────────────── - Object.values(skillTools).forEach((toolDef) => { + Object.values(skillTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -993,17 +999,20 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); @@ -1039,22 +1048,26 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); // ── Compression Tools ───────────────────────── - Object.values(compressionTools).forEach((toolDef) => { + Object.values(compressionTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -1062,17 +1075,20 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); @@ -1085,17 +1101,48 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) + ); + }); + + // ── Notion Context Source Tools ─────────────── + notionTools.forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) ); }); diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 382a977634..91075f7de4 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -299,6 +299,7 @@ export const compressionTools = { name: "omniroute_compression_status", description: "Returns current compression configuration, strategy, analytics summary (requests compressed, tokens saved, avg ratio), and provider-aware cache statistics.", + scopes: ["read:compression"], inputSchema: compressionStatusInput, handler: (args: z.infer) => handleCompressionStatus(args), }, @@ -306,24 +307,28 @@ export const compressionTools = { name: "omniroute_compression_configure", description: "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", + scopes: ["write:compression"], inputSchema: compressionConfigureInput, handler: (args: z.infer) => handleCompressionConfigure(args), }, omniroute_set_compression_engine: { name: "omniroute_set_compression_engine", description: "Set the active compression engine and Caveman/RTK runtime options.", + scopes: ["write:compression"], inputSchema: setCompressionEngineInput, handler: (args: z.infer) => handleSetCompressionEngine(args), }, omniroute_list_compression_combos: { name: "omniroute_list_compression_combos", description: "List compression combos and their engine pipelines.", + scopes: ["read:compression"], inputSchema: listCompressionCombosInput, handler: (_args: z.infer) => handleListCompressionCombos(), }, omniroute_compression_combo_stats: { name: "omniroute_compression_combo_stats", description: "Get compression analytics grouped by engine and compression combo.", + scopes: ["read:compression"], inputSchema: compressionComboStatsInput, handler: (args: z.infer) => handleCompressionComboStats(args), diff --git a/open-sse/mcp-server/tools/gamificationTools.ts b/open-sse/mcp-server/tools/gamificationTools.ts index de42fb2ff8..99321aa5a8 100644 --- a/open-sse/mcp-server/tools/gamificationTools.ts +++ b/open-sse/mcp-server/tools/gamificationTools.ts @@ -10,6 +10,7 @@ export const gamificationTools = [ { name: "gamification_leaderboard", description: "Get leaderboard rankings for a scope (global, weekly, monthly, tokens_shared).", + scopes: ["read:gamification"], inputSchema: z.object({ scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), limit: z.number().min(1).max(100).default(50), @@ -23,6 +24,7 @@ export const gamificationTools = [ { name: "gamification_rank", description: "Get rank for an API key in a leaderboard scope.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), @@ -36,6 +38,7 @@ export const gamificationTools = [ { name: "gamification_profile", description: "Get XP, level, and badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), }), @@ -64,6 +67,7 @@ export const gamificationTools = [ { name: "gamification_badges", description: "List all badge definitions or earned badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string().optional(), category: z.string().optional(), @@ -83,6 +87,7 @@ export const gamificationTools = [ { name: "gamification_transfer", description: "Transfer tokens between API keys.", + scopes: ["write:gamification"], inputSchema: z.object({ fromApiKeyId: z.string(), toApiKeyId: z.string(), @@ -108,6 +113,7 @@ export const gamificationTools = [ { name: "gamification_invite", description: "Create an invite token for server connection.", + scopes: ["write:gamification"], inputSchema: z.object({ apiKeyId: z.string(), serverUrl: z.string().optional(), @@ -122,6 +128,7 @@ export const gamificationTools = [ { name: "gamification_servers", description: "List connected community servers.", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { listServers } = await import("../../../src/lib/gamification/servers"); @@ -131,6 +138,7 @@ export const gamificationTools = [ { name: "gamification_anomalies", description: "Get flagged anomalous XP activity (admin only).", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { getAnomalies } = await import("../../../src/lib/gamification/antiCheat"); diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index b5ed467a95..a41cdeebcd 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -31,6 +31,7 @@ export const memoryTools = { omniroute_memory_search: { name: "omniroute_memory_search", description: "Search memories by query, type, or API key with token budget enforcement", + scopes: ["read:memory"], inputSchema: MemorySearchSchema, handler: async (args: z.infer) => { // Plan 21 D16/Bug#7 fix: even on the error path the fallback must @@ -66,6 +67,7 @@ export const memoryTools = { omniroute_memory_add: { name: "omniroute_memory_add", description: "Add a new memory entry", + scopes: ["write:memory"], inputSchema: MemoryAddSchema, handler: async (args: z.infer) => { const memory = await createMemory({ @@ -91,6 +93,7 @@ export const memoryTools = { omniroute_memory_clear: { name: "omniroute_memory_clear", description: "Clear memories for an API key, optionally filtered by type or age", + scopes: ["write:memory"], inputSchema: MemoryClearSchema, handler: async (args: z.infer) => { const result = await listMemories({ diff --git a/open-sse/mcp-server/tools/notionTools.ts b/open-sse/mcp-server/tools/notionTools.ts new file mode 100644 index 0000000000..2415482a94 --- /dev/null +++ b/open-sse/mcp-server/tools/notionTools.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { createNotionClient } from "../../../src/lib/notion/api.ts"; +import { getNotionToken } from "../../../src/lib/db/notion.ts"; + +function requireToken(): string { + const token = getNotionToken(); + if (!token) throw new Error("Notion integration token not configured. Set it in Settings > Context Sources."); + return token; +} + +export const notionTools = [ + { + name: "notion_search", + description: "Search pages and databases in Notion by text query. Returns matching page titles, IDs, and URL.", + scopes: ["read:notion"], + inputSchema: z.object({ + query: z.string().min(1).max(500).describe("Search query text"), + pageSize: z.number().min(1).max(100).default(20).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { query: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.searchPagesAndDatabases(args.query, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_get_page", + description: "Get the content and metadata of a Notion page by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + pageId: z.string().min(1).describe("Notion page ID (32-char hex or UUID)"), + }), + handler: async (args: { pageId: string }) => { + const client = createNotionClient(requireToken()); + return client.getPage(args.pageId); + }, + }, + { + name: "notion_list_block_children", + description: "List all block children of a Notion block or page. Returns the block tree structure.", + scopes: ["read:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Block ID to fetch children from"), + pageSize: z.number().min(1).max(100).default(50).describe("Blocks per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { blockId: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.listBlockChildren(args.blockId, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_query_database", + description: "Query a Notion database with optional filters and sorts. Returns matching entries.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + filter: z.unknown().optional().describe("Optional filter object (Notion API filter format)"), + sorts: z.array(z.unknown()).optional().describe("Optional sort array (Notion API sort format)"), + pageSize: z.number().min(1).max(100).default(50).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { + databaseId: string; + filter?: unknown; + sorts?: unknown[]; + pageSize?: number; + startCursor?: string; + }) => { + const client = createNotionClient(requireToken()); + return client.queryDatabase( + args.databaseId, + args.filter, + args.sorts, + args.startCursor, + args.pageSize + ); + }, + }, + { + name: "notion_get_database", + description: "Get metadata and schema of a Notion database by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + }), + handler: async (args: { databaseId: string }) => { + const client = createNotionClient(requireToken()); + return client.getDatabase(args.databaseId); + }, + }, + { + name: "notion_append_blocks", + description: "Append block children to an existing Notion block or page. Maximum 100 blocks per request.", + scopes: ["write:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Target block or page ID to append to"), + children: z.array(z.unknown()).describe("Array of block objects to append"), + after: z.string().optional().describe("Block ID to append after (position parameter)"), + }), + handler: async (args: { blockId: string; children: unknown[]; after?: string }) => { + const client = createNotionClient(requireToken()); + return client.appendBlocks(args.blockId, args.children, args.after); + }, + }, +]; diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts index a6d4a3f9b1..ed8dd9be97 100644 --- a/open-sse/mcp-server/tools/pluginTools.ts +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -12,6 +12,7 @@ export const pluginTools = [ { name: "plugin_list", description: "List all installed plugins with their status, hooks, and metadata.", + scopes: ["read:plugins"], inputSchema: z.object({ status: z .enum(["installed", "active", "inactive", "error"]) @@ -39,6 +40,7 @@ export const pluginTools = [ { name: "plugin_install", description: "Install a plugin from a local directory path.", + scopes: ["write:plugins"], inputSchema: z.object({ path: z.string().describe("Absolute path to the plugin directory containing plugin.json"), }), @@ -58,6 +60,7 @@ export const pluginTools = [ { name: "plugin_activate", description: "Activate an installed plugin (loads hooks into the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -70,6 +73,7 @@ export const pluginTools = [ { name: "plugin_deactivate", description: "Deactivate an active plugin (unloads hooks from the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -82,6 +86,7 @@ export const pluginTools = [ { name: "plugin_uninstall", description: "Uninstall a plugin (deactivates, removes files, removes from DB).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -94,6 +99,7 @@ export const pluginTools = [ { name: "plugin_configure", description: "Get or update a plugin's configuration.", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name"), config: z @@ -122,6 +128,7 @@ export const pluginTools = [ { name: "plugin_executions", description: "View plugin execution history (from skill_executions table).", + scopes: ["read:plugins"], inputSchema: z.object({ name: z.string().optional().describe("Filter by plugin name"), limit: z.number().min(1).max(100).default(20).describe("Max results to return"), @@ -137,6 +144,7 @@ export const pluginTools = [ { name: "plugin_scan", description: "Scan the plugin directory for new plugins and sync with DB.", + scopes: ["write:plugins"], inputSchema: z.object({}), handler: async () => { const result = await pluginManager.scan(); diff --git a/open-sse/mcp-server/tools/poolTools.ts b/open-sse/mcp-server/tools/poolTools.ts new file mode 100644 index 0000000000..bf21767e2a --- /dev/null +++ b/open-sse/mcp-server/tools/poolTools.ts @@ -0,0 +1,158 @@ +/** + * OmniRoute MCP Session Pool Tools — Manage and monitor anonymous web session pools. + * + * Tools: + * 1. omniroute_pool_status — Get pool stats for one or all providers + * 2. omniroute_pool_sessions — List per-session details for a provider's pool + * 3. omniroute_pool_reset — Shut down and recreate a pool + * 4. omniroute_pool_warm — Warm up a pool to a target session count + */ + +import { z } from "zod"; +import { PoolRegistry } from "../../services/sessionPool/poolRegistry.ts"; + +// ─── Input Schemas ───────────────────────────────────────────────────────── + +export const poolStatusInput = z.object({ + provider: z + .string() + .optional() + .describe("Provider name (e.g. 'pollinations'). Omit to list all pools"), +}); + +export const poolSessionsInput = z.object({ + provider: z.string().describe("Provider name (e.g. 'pollinations')"), +}); + +export const poolResetInput = z.object({ + provider: z.string().describe("Provider name (e.g. 'pollinations')"), +}); + +export const poolWarmInput = z.object({ + provider: z.string().describe("Provider name (e.g. 'pollinations')"), + count: z + .number() + .int() + .min(1) + .max(50) + .default(6) + .describe("Target session count (1–50)"), +}); + +// ─── Handlers ────────────────────────────────────────────────────────────── + +/** + * Handle pool_status tool: return stats for one or all pools + */ +export async function handlePoolStatus( + args: z.infer, +): Promise> { + if (args.provider) { + const stats = PoolRegistry.getStats(args.provider); + if (!stats) { + return { error: `No pool found for provider '${args.provider}'` }; + } + return { provider: args.provider, stats }; + } + + const all = PoolRegistry.getAllStats(); + return { + totalPools: all.length, + providers: PoolRegistry.listProviders(), + pools: all, + }; +} + +/** + * Handle pool_sessions tool: list per-session details for a provider's pool + */ +export async function handlePoolSessions( + args: z.infer, +): Promise> { + const details = PoolRegistry.getSessionDetails(args.provider); + if (!details) { + return { error: `No pool found for provider '${args.provider}'` }; + } + + const stats = PoolRegistry.getStats(args.provider); + return { + provider: args.provider, + sessionCount: details.length, + stats, + sessions: details, + }; +} + +/** + * Handle pool_reset tool: shut down and recreate a pool + */ +export async function handlePoolReset( + args: z.infer, +): Promise> { + const existed = PoolRegistry.resetPool(args.provider); + return { + provider: args.provider, + reset: existed, + message: existed + ? `Pool '${args.provider}' shut down and removed. It will be recreated on next request.` + : `No pool found for provider '${args.provider}'`, + }; +} + +/** + * Handle pool_warm tool: warm up a pool to a target session count + */ +export async function handlePoolWarm( + args: z.infer, +): Promise> { + // If pool doesn't exist yet, we can't warm it + const pool = PoolRegistry.getPool(args.provider); + if (!pool) { + return { error: `No pool found for provider '${args.provider}'` }; + } + + const before = pool.totalCount; + await pool.warmUp(args.count); + const after = pool.totalCount; + + return { + provider: args.provider, + targetCount: args.count, + sessionsBefore: before, + sessionsAfter: after, + created: after - before, + }; +} + +// ─── Tool Registry ───────────────────────────────────────────────────────── + +export const poolTools = { + omniroute_pool_status: { + name: "omniroute_pool_status", + description: + "Returns session pool status for a specific provider or all providers. Includes session counts by state (active/cooldown/dead), request totals, success rate, and throughput.", + inputSchema: poolStatusInput, + handler: (args: z.infer) => handlePoolStatus(args), + }, + omniroute_pool_sessions: { + name: "omniroute_pool_sessions", + description: + "Lists all sessions in a provider's pool with per-session details: fingerprint, status, request counts, inflight, cooldown remaining, and age.", + inputSchema: poolSessionsInput, + handler: (args: z.infer) => handlePoolSessions(args), + }, + omniroute_pool_reset: { + name: "omniroute_pool_reset", + description: + "Shuts down and removes all sessions for a provider's pool. A new pool will be created automatically on the next request.", + inputSchema: poolResetInput, + handler: (args: z.infer) => handlePoolReset(args), + }, + omniroute_pool_warm: { + name: "omniroute_pool_warm", + description: + "Warms a session pool to the specified session count (1–50). Sessions beyond the current count are created with fresh browser fingerprints.", + inputSchema: poolWarmInput, + handler: (args: z.infer) => handlePoolWarm(args), + }, +}; diff --git a/open-sse/mcp-server/tools/skillTools.ts b/open-sse/mcp-server/tools/skillTools.ts index 2ea5d8ddb9..bb3f0acb6e 100644 --- a/open-sse/mcp-server/tools/skillTools.ts +++ b/open-sse/mcp-server/tools/skillTools.ts @@ -25,6 +25,7 @@ export const skillTools = { omniroute_skills_list: { name: "omniroute_skills_list", description: "List all registered skills with optional filtering by API key or name", + scopes: ["read:skills"], inputSchema: SkillListSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -55,6 +56,7 @@ export const skillTools = { omniroute_skills_enable: { name: "omniroute_skills_enable", description: "Enable or disable a specific skill by ID", + scopes: ["write:skills"], inputSchema: SkillEnableSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -70,6 +72,7 @@ export const skillTools = { omniroute_skills_execute: { name: "omniroute_skills_execute", description: "Execute a skill with provided input and return the result", + scopes: ["execute:skills"], inputSchema: SkillExecuteSchema, handler: async (args: z.infer) => { const execution = await skillExecutor.execute(args.skillName, args.input, { @@ -92,6 +95,7 @@ export const skillTools = { omniroute_skills_executions: { name: "omniroute_skills_executions", description: "List recent skill execution history", + scopes: ["read:skills"], inputSchema: z.object({ apiKeyId: z.string().optional(), limit: z.number().int().positive().max(100).optional(), diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 82cfb698ef..62527fb482 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1323,6 +1323,18 @@ export function checkFallbackError( }; } + // #2929: A route-restriction 403 (e.g. Fireworks Fire Pass keys returning + // "Fire Pass API keys are not authorized for this route." on the /models + // endpoint) means the key is valid but lacks access to THIS route — it still + // serves chat. It must NOT cool down the connection or be classified as an + // auth error, otherwise a single model-listing 403 marks the key unavailable. + if ( + status === HTTP_STATUS.FORBIDDEN && + errorStr.toLowerCase().includes("not authorized for this route") + ) { + return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; + } + if ( status === HTTP_STATUS.FORBIDDEN && provider && diff --git a/open-sse/services/apiKeyRotator.ts b/open-sse/services/apiKeyRotator.ts index 1814fb833d..58a1337f0d 100644 --- a/open-sse/services/apiKeyRotator.ts +++ b/open-sse/services/apiKeyRotator.ts @@ -21,6 +21,9 @@ const _keyIndexes = new Map(); // Tracks which connections have extra API keys (for A3 guard in chatCore.ts) // Used to prevent disabling an entire connection when only one key fails. const _connectionExtraKeys = new Map(); +// Eviction limits to prevent unbounded memory growth under heavy load +const MAX_KEY_HEALTH_ENTRIES = 500; +const MAX_CONNECTION_EXTRA_KEYS = 500; /** * Record whether a connection has extra API keys. @@ -28,6 +31,10 @@ const _connectionExtraKeys = new Map(); */ export function trackConnectionExtraKeys(connectionId: string, extraKeys: string[]): void { const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); + if (!_connectionExtraKeys.has(connectionId) && _connectionExtraKeys.size >= MAX_CONNECTION_EXTRA_KEYS) { + const oldest = _connectionExtraKeys.keys().next().value; + if (oldest !== undefined) _connectionExtraKeys.delete(oldest); + } _connectionExtraKeys.set(connectionId, validExtras.length > 0); } @@ -64,6 +71,10 @@ const FAILURE_THRESHOLD = 2; // Mark as invalid after 2 consecutive failures function getOrCreateHealth(connectionId: string, keyId: string): KeyHealth { const scopedKey = `${connectionId}:${keyId}`; if (!_keyHealth.has(scopedKey)) { + if (_keyHealth.size >= MAX_KEY_HEALTH_ENTRIES) { + const oldest = _keyHealth.keys().next().value; + if (oldest !== undefined) _keyHealth.delete(oldest); + } _keyHealth.set(scopedKey, { status: "active", failures: 0, @@ -265,7 +276,12 @@ export function syncHealthFromDB(connectionId: string, health?: Record= MAX_KEY_HEALTH_ENTRIES) { + const oldest = _keyHealth.keys().next().value; + if (oldest !== undefined) _keyHealth.delete(oldest); + } + _keyHealth.set(scopedKey, keyHealth); } } @@ -336,6 +352,11 @@ export function removeConnectionHealth(connectionId: string): void { export function removeConnectionIndex(connectionId: string): void { _keyIndexes.delete(connectionId); _connectionExtraKeys.delete(connectionId); + for (const key of _keyHealth.keys()) { + if (key.startsWith(`${connectionId}:`)) { + _keyHealth.delete(key); + } + } } export type { KeyHealth }; diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index 83c5138703..eb9a5f79a5 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -114,7 +114,7 @@ export const CCH_SALT = "59cf53e54c78"; /** Character positions sampled from the first user message text. */ export const CCH_POSITIONS = [4, 7, 20] as const; /** Default `cc_version=` value embedded in the billing header. */ -export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.146"; +export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.158"; /** Identity sentinel prepended for Claude Agent SDK callers. */ export const CLAUDE_AGENT_SDK_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c7c6800687..1eb33cca15 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -38,9 +38,10 @@ export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [ "claude-code-20250219", "interleaved-thinking-2025-05-14", "effort-2025-11-24", + "redact-thinking-2026-02-12", ].join(","); -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.146"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.146 (external, sdk-cli)"; +export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.158"; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.158 (external, sdk-cli)"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"; export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"; diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 9b61b826f7..16c97a7b3b 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -144,3 +144,163 @@ export function remapToolNamesInResponse( } export { TOOL_RENAME_MAP, REVERSE_MAP }; + +/** + * Anthropic fingerprints third-party agent harnesses by their tool NAMES on the + * first-party Messages API (native Claude OAuth). Two failure modes, both + * surfaced as a misleading `400 out of extra usage` placeholder (the SSE stream + * is refused, not a real billing event): + * 1. Specific blacklisted names (e.g. `mixture_of_agents`) are refused even in + * isolation. + * 2. A large enough SET of recognizable snake_case agent tool names is + * refused collectively, even though each name passes on its own. + * + * `remapToolNamesInRequest` only normalizes the fixed set of Claude Code tool + * names. This generalizes that cloak: any tool name that does not already look + * like a genuine Claude Code tool (PascalCase, no separators) is deterministically + * aliased — to its Claude Code canonical equivalent when one exists, otherwise to + * a PascalCase form of the original. The per-request alias is tracked in the + * non-enumerable `_toolNameMap`, so `remapToolNamesInResponse` restores the + * caller's original names transparently. Disable with + * `CLAUDE_DISABLE_TOOL_NAME_CLOAK=true`. + */ +const CLAUDE_BUILTIN_TOOL_NAMES = new Set(Object.values(TOOL_RENAME_MAP)); + +const HARNESS_CANONICAL_MAP: Record = { + read_file: "Read", + write_file: "Write", + search_files: "Grep", + grep_search: "Grep", + list_directory: "Glob", + run_command: "Bash", + terminal: "Bash", + todo: "TodoWrite", + todo_write: "TodoWrite", + todo_read: "TodoRead", + patch: "Edit", + multi_edit: "MultiEdit", +}; + +function toPascalCaseToolName(name: string): string { + const parts = name.split(/[_\s-]+/).filter(Boolean); + const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + return pascal || name; +} + +/** + * A name is left untouched when it already reads as a genuine Claude Code tool: + * a PascalCase single token with no separators (Bash, Read, TodoWrite). + */ +export function needsThirdPartyCloak(name: string): boolean { + if (!name) return false; + if (CLAUDE_BUILTIN_TOOL_NAMES.has(name)) return false; + return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-"); +} + +export interface CloakOptions { + /** + * Names matching this predicate are left untouched, so a caller that owns a + * more specific rewrite (e.g. the CliproxyAPI executor's Anthropic `mcp_*` + * reserved-namespace rewrite) keeps authority over them and the two reverse + * maps stay disjoint / single-hop. + */ + skip?: (name: string) => boolean; +} + +export function cloakThirdPartyToolNames( + body: Record, + options?: CloakOptions +): Map { + // Operator kill-switch (documented in .env.example / ENVIRONMENT.md). Checked + // here so every call site — native base.ts AND the CLIProxyAPI executor — + // honours it, rather than each caller having to remember to guard. + if (process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK === "true") { + return new Map(); + } + const shouldCloak = (name: string): boolean => + needsThirdPartyCloak(name) && !(options?.skip ? options.skip(name) : false); + const tools = body.tools as Array> | undefined; + + const used = new Set(); + if (Array.isArray(tools)) { + for (const tool of tools) { + if (tool && typeof tool.name === "string") used.add(tool.name); + } + } + const existingMap = + body._toolNameMap instanceof Map ? (body._toolNameMap as Map) : null; + if (existingMap) { + for (const alias of existingMap.keys()) used.add(alias); + } + + // Created lazily so genuine Claude Code traffic (nothing to cloak) does not + // get an empty _toolNameMap attached to the request body. + let nameMap: Map | null = existingMap; + const assigned = new Map(); // original -> alias + + const aliasFor = (original: string): string => { + const existing = assigned.get(original); + if (existing) return existing; + // Prefer the established Claude Code rename maps (TOOL_RENAME_MAP spreads + // EXTRA_TOOL_RENAME_MAP) so the CPA path matches the native path exactly: + // subagents->SubDispatch, session_status->CheckStatus, webfetch->WebFetch, … + // Then harness-canonical (read_file->Read), then a generic PascalCase. + const base = + TOOL_RENAME_MAP[original] ?? HARNESS_CANONICAL_MAP[original] ?? toPascalCaseToolName(original); + let alias = base; + let suffix = 2; + while (alias !== original && used.has(alias)) { + alias = `${base}${suffix++}`; + } + used.delete(original); + used.add(alias); + assigned.set(original, alias); + if (!nameMap) nameMap = getRequestToolNameMap(body); + nameMap.set(alias, original); + return alias; + }; + + // Non-mutating: clone changed entries rather than rewriting the caller's + // objects in place (mirrors applyMcpToolNameRewrite — transformRequest must + // not corrupt an input body that may be logged or replayed on fallback). + if (Array.isArray(tools)) { + body.tools = tools.map((tool) => { + if (tool && typeof tool.name === "string" && shouldCloak(tool.name)) { + return { ...tool, name: aliasFor(tool.name) }; + } + return tool; + }); + } + + const messages = body.messages as Array> | undefined; + if (Array.isArray(messages)) { + body.messages = messages.map((message) => { + const content = message?.content as Array> | undefined; + if (!Array.isArray(content)) return message; + let changed = false; + const newContent = content.map((block) => { + if ( + block?.type === "tool_use" && + typeof block.name === "string" && + shouldCloak(block.name) + ) { + changed = true; + return { ...block, name: aliasFor(block.name) }; + } + return block; + }); + return changed ? { ...message, content: newContent } : message; + }); + } + + const toolChoice = body.tool_choice as Record | undefined; + if ( + toolChoice?.type === "tool" && + typeof toolChoice.name === "string" && + shouldCloak(toolChoice.name) + ) { + body.tool_choice = { ...toolChoice, name: aliasFor(toolChoice.name) }; + } + + return nameMap ?? new Map(); +} diff --git a/open-sse/services/codexQuotaFetcher.ts b/open-sse/services/codexQuotaFetcher.ts index f800f3dedb..ef40c48286 100644 --- a/open-sse/services/codexQuotaFetcher.ts +++ b/open-sse/services/codexQuotaFetcher.ts @@ -76,6 +76,7 @@ interface CodexConnectionMeta { const MAX_CONNECTIONS = 100; const connectionRegistry = new Map(); +const MAX_QUOTA_CACHE_ENTRIES = 200; /** * Register Codex connection metadata for quota fetching. @@ -85,7 +86,7 @@ const connectionRegistry = new Map(); * @param meta - Access token and optional workspace ID */ export function registerCodexConnection(connectionId: string, meta: CodexConnectionMeta): void { - if (connectionRegistry.size >= MAX_CONNECTIONS) { + if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) { const oldestKey = connectionRegistry.keys().next().value; if (oldestKey !== undefined) { quotaCache.delete(oldestKey); @@ -123,6 +124,13 @@ function getCodexConnectionMeta( if (accessToken) { const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) }; + if (!connectionRegistry.has(connectionId) && connectionRegistry.size >= MAX_CONNECTIONS) { + const oldestKey = connectionRegistry.keys().next().value; + if (oldestKey !== undefined) { + quotaCache.delete(oldestKey); + connectionRegistry.delete(oldestKey); + } + } connectionRegistry.set(connectionId, meta); return meta; } @@ -204,6 +212,10 @@ export async function fetchCodexQuota( if (!quota) return null; // Store in cache + if (!quotaCache.has(connectionId) && quotaCache.size >= MAX_QUOTA_CACHE_ENTRIES) { + const oldestCacheKey = quotaCache.keys().next().value; + if (oldestCacheKey !== undefined) quotaCache.delete(oldestCacheKey); + } quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); return quota; } catch { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 3fcb7c8fee..aafd147238 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -495,6 +495,10 @@ export async function validateResponseQuality( // In-memory atomic counter per combo for round-robin distribution // Resets on server restart (by design — no stale state) +// Eviction limits to prevent unbounded memory growth +const MAX_RR_COUNTERS = 500; +const MAX_RESET_AWARE_CACHE = 200; + const rrCounters = new Map(); const resetAwareConnectionCache = new Map< @@ -1627,6 +1631,10 @@ async function getQuotaAwareConnectionsForTarget( const activeConnections = Array.isArray(connections) ? (connections as Array>) : []; + if (!resetAwareConnectionCache.has(provider) && resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE) { + const oldest = resetAwareConnectionCache.keys().next().value; + if (oldest !== undefined) resetAwareConnectionCache.delete(oldest); + } resetAwareConnectionCache.set(provider, { connections: activeConnections, fetchedAt: Date.now(), @@ -1758,6 +1766,10 @@ async function fetchResetAwareQuotaWithCache({ const refreshPromise = fetcher(connectionId, connection) .then((quota) => { if (quota) { + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + const oldest = resetAwareQuotaCache.keys().next().value; + if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); + } resetAwareQuotaCache.set(cacheKey, { quota, fetchedAt: Date.now(), @@ -1771,6 +1783,10 @@ async function fetchResetAwareQuotaWithCache({ .catch((error) => { const previous = resetAwareQuotaCache.get(cacheKey); if (previous) { + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + const oldest = resetAwareQuotaCache.keys().next().value; + if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); + } resetAwareQuotaCache.set(cacheKey, { ...previous, refreshPromise: null }); } log.warn?.("COMBO", "Reset-aware quota fetch failed.", { @@ -1783,6 +1799,10 @@ async function fetchResetAwareQuotaWithCache({ return null; }); + if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + const oldest = resetAwareQuotaCache.keys().next().value; + if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); + } resetAwareQuotaCache.set(cacheKey, { quota: existing?.quota ?? cached?.quota ?? null, fetchedAt: existing?.fetchedAt ?? cached?.fetchedAt ?? 0, @@ -1906,6 +1926,10 @@ async function orderTargetsByResetAwareQuota( if (tiedTargets.length > 1) { const key = `reset-aware:${comboName}`; const counter = rrCounters.get(key) || 0; + if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) { + const oldest = rrCounters.keys().next().value; + if (oldest !== undefined) rrCounters.delete(oldest); + } rrCounters.set(key, counter + 1); const startIndex = counter % tiedTargets.length; orderedTiedTargets = [...tiedTargets.slice(startIndex), ...tiedTargets.slice(0, startIndex)]; @@ -2065,6 +2089,10 @@ async function orderTargetsByResetWindow( const key = `reset-window:${comboName}`; const counter = rrCounters.get(key) || 0; + if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) { + const oldest = rrCounters.keys().next().value; + if (oldest !== undefined) rrCounters.delete(oldest); + } rrCounters.set(key, counter + 1); const startIndex = counter % tiedTargets.length; const orderedTiedTargets = [ @@ -3289,7 +3317,7 @@ export async function handleComboChat({ if (isModelAvailable) { const available = await isModelAvailable(modelStr, targetForAttempt); if (!available) { - log.info("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`); + log.debug?.("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`); if (i > 0) fallbackCount++; return null; } @@ -3802,7 +3830,7 @@ export async function handleComboChat({ ? Math.min(cooldownMs, fallbackDelayMs) : 0; if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { - log.info("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`); + log.debug?.("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`); await new Promise((resolve) => { const timer = setTimeout(resolve, fallbackWaitMs); signal?.addEventListener( @@ -3990,6 +4018,10 @@ async function handleRoundRobinCombo({ // Get and increment atomic counter const counter = rrCounters.get(combo.name) || 0; + if (!rrCounters.has(combo.name) && rrCounters.size >= MAX_RR_COUNTERS) { + const oldest = rrCounters.keys().next().value; + if (oldest !== undefined) rrCounters.delete(oldest); + } rrCounters.set(combo.name, counter + 1); const startIndex = counter % modelCount; @@ -4026,7 +4058,7 @@ async function handleRoundRobinCombo({ if (isModelAvailable) { const available = await isModelAvailable(modelStr, targetForAttempt); if (!available) { - log.info("COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded`); + log.debug?.("COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded`); if (offset > 0) fallbackCount++; continue; } @@ -4267,7 +4299,7 @@ async function handleRoundRobinCombo({ isAllAccountsRateLimited); if (providerExhausted) { exhaustedProviders.add(provider); - log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`); + log.debug?.("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`); } else if ( result.status === 429 && !isTokenLimitBreach && @@ -4324,7 +4356,7 @@ async function handleRoundRobinCombo({ ? Math.min(cooldownMs, fallbackDelayMs) : 0; if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { - log.info("COMBO-RR", `Waiting ${fallbackWaitMs}ms before fallback to next model`); + log.debug?.("COMBO-RR", `Waiting ${fallbackWaitMs}ms before fallback to next model`); await new Promise((resolve) => { const timer = setTimeout(resolve, fallbackWaitMs); signal?.addEventListener( diff --git a/open-sse/services/comboMetrics.ts b/open-sse/services/comboMetrics.ts index 6e33aafdb2..6c6e8e71da 100644 --- a/open-sse/services/comboMetrics.ts +++ b/open-sse/services/comboMetrics.ts @@ -188,6 +188,40 @@ function toMetricView( // In-memory store const metrics = new Map(); const shadowMetrics = new Map(); +const MAX_METRICS_ENTRIES = 500; +const METRICS_TTL_MS = 60 * 60 * 1000; // 1 hour + +function evictOldestMetric(targetMap: Map): void { + let oldest: string | null = null; + let oldestTime = Infinity; + for (const [name, entry] of targetMap) { + const t = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : Date.now(); + if (t < oldestTime) { oldestTime = t; oldest = name; } + } + if (oldest) { + metrics.delete(oldest); + shadowMetrics.delete(oldest); + } +} + +const _metricsCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [name, entry] of metrics) { + const lastUsed = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : now; + if (now - lastUsed > METRICS_TTL_MS) { + metrics.delete(name); + shadowMetrics.delete(name); + } + } + for (const [name, entry] of shadowMetrics) { + const lastUsed = entry.lastUsedAt ? new Date(entry.lastUsedAt).getTime() : now; + if (now - lastUsed > METRICS_TTL_MS) { + metrics.delete(name); + shadowMetrics.delete(name); + } + } +}, 5 * 60 * 1000); // every 5 minutes +_metricsCleanupTimer.unref?.(); // Don't prevent process exit /** * Record a combo request result. @@ -217,6 +251,9 @@ export function recordComboRequest( target?: ComboRequestTargetMeta | null; } ): void { + if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(metrics); + } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry(strategy)); } @@ -283,6 +320,9 @@ export function recordComboShadowRequest( target?: ComboRequestTargetMeta | null; } ): void { + if (!shadowMetrics.has(comboName) && shadowMetrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(shadowMetrics); + } if (!shadowMetrics.has(comboName)) { shadowMetrics.set(comboName, createShadowEntry()); } @@ -396,6 +436,9 @@ export function getAllComboMetrics(): Record { * Record detected prompt intent for a combo (used by multilingual routing analytics). */ export function recordComboIntent(comboName: string, intent: string): void { + if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(metrics); + } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry("priority")); } @@ -418,6 +461,7 @@ export function resetComboMetrics(comboName: string): void { * Reset all combo metrics. */ export function resetAllComboMetrics(): void { + clearInterval(_metricsCleanupTimer); metrics.clear(); shadowMetrics.clear(); } diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 83eac8100c..2f3ca61d1a 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -38,6 +38,10 @@ ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; // 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"). +ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. @@ -108,7 +112,16 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } } const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); -const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["gpt-5.5"]); +// #2877(B): include the effort-suffixed variants so a bare `gpt-5.5-xhigh` +// (and -high/-medium/-low) infers the codex provider instead of falling through +// the `/^gpt-/` → openai fallback (which 500s for codex-only credentials). +const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set([ + "gpt-5.5", + "gpt-5.5-xhigh", + "gpt-5.5-high", + "gpt-5.5-medium", + "gpt-5.5-low", +]); const CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES = new Map([["gpt-5.5", "gpt-5.5-medium"]]); export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]); diff --git a/open-sse/services/payloadRules.ts b/open-sse/services/payloadRules.ts index 4422476b37..98ad426ee1 100644 --- a/open-sse/services/payloadRules.ts +++ b/open-sse/services/payloadRules.ts @@ -215,11 +215,36 @@ export function clearPayloadRulesConfigOverride() { runtimeOverride = null; } +// #2986: Read the DB-persisted payload rules (the source of truth, written by +// the Settings UI via updateSettings). Used as the fallback when no in-memory +// runtimeOverride is set — e.g. a fresh process before the startup +// applyRuntimeSettings hook ran, or a separate module instance in the +// standalone Next.js build — so saved rules survive a server restart instead of +// silently reverting to the (usually empty) file config. +async function loadPayloadRulesFromSettings(): Promise { + try { + const { getCachedSettings } = await import("@/lib/localDb"); + const settings = (await getCachedSettings()) as { payloadRules?: unknown }; + const raw = settings?.payloadRules; + if (raw === null || raw === undefined) return null; + return normalizePayloadRulesConfig(raw); + } catch { + return null; + } +} + export async function getPayloadRulesConfig(options: { forceRefresh?: boolean } = {}) { if (runtimeOverride) { return clonePayloadRulesConfig(runtimeOverride); } + // #2986: prefer the DB-persisted rules over the file config so a saved + // configuration survives a restart even when the in-memory override is absent. + const dbConfig = await loadPayloadRulesFromSettings(); + if (dbConfig) { + return clonePayloadRulesConfig(dbConfig); + } + await refreshPayloadRulesFileCache(options.forceRefresh === true); return clonePayloadRulesConfig(cachedFileConfig); } diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index a46a016574..c5a4a181ae 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -192,6 +192,20 @@ function reconcileEnabledConnections( function watchdogTick() { const now = Date.now(); + // Clean up idle limiters that haven't been used recently + for (const [key, limiter] of Array.from(limiters)) { + const lastUsed = limiterLastUsed.get(key) ?? 0; + if (now - lastUsed > INACTIVE_LIMITER_MS) { + const counts = limiter.counts(); + if (counts.QUEUED === 0 && counts.RUNNING === 0 && counts.EXECUTING === 0) { + limiters.delete(key); + lastDispatchAt.delete(key); + limiterLastUsed.delete(key); + logRateLimit(`🧹 [RATE-LIMIT] Evicting idle limiter: ${key} (inactive for ${Math.round((now - lastUsed) / 1000)}s)`); + trackAsyncOperation(limiter.disconnect()); + } + } + } for (const [key, limiter] of Array.from(limiters)) { const counts = limiter.counts(); if (counts.QUEUED === 0) continue; @@ -211,6 +225,7 @@ function watchdogTick() { ); limiters.delete(key); lastDispatchAt.delete(key); + limiterLastUsed.delete(key); // Do NOT call limiter.stop() — it permanently rejects future .schedule() calls with // "This limiter has been stopped". In-flight requests still holding a reference to // the old instance cannot be redirected to a new one, causing spurious 502 bursts. @@ -257,6 +272,7 @@ function shutdownLimiters(): void { } limiters.clear(); lastDispatchAt.clear(); + limiterLastUsed.clear(); } // Only register shutdown handlers when there are active limiters to shut down. @@ -349,6 +365,7 @@ export function disableRateLimitProtection(connectionId) { if (key.includes(connectionId)) { limiters.delete(key); lastDispatchAt.delete(key); + limiterLastUsed.delete(key); trackAsyncOperation(limiter.disconnect()); } } @@ -403,8 +420,10 @@ function getLimiter(provider, connectionId, model = null) { limiters.set(key, limiter); lastDispatchAt.set(key, Date.now()); + limiterLastUsed.set(key, Date.now()); } + limiterLastUsed.set(key, Date.now()); return limiters.get(key); } @@ -626,6 +645,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model // the abandoned Bottleneck; under sustained quota pressure that is a real leak. limiters.delete(limiterKey); lastDispatchAt.delete(limiterKey); + limiterLastUsed.delete(limiterKey); trackAsyncOperation(limiter.disconnect()); return; } @@ -799,6 +819,7 @@ export async function __resetRateLimitManagerForTests() { enabledConnections.clear(); initialized = false; lastDispatchAt.clear(); + limiterLastUsed.clear(); shutdownHandlersRegistered = false; for (const key of Object.keys(learnedLimits)) { diff --git a/open-sse/services/refreshSerializer.ts b/open-sse/services/refreshSerializer.ts new file mode 100644 index 0000000000..10d044de9a --- /dev/null +++ b/open-sse/services/refreshSerializer.ts @@ -0,0 +1,107 @@ +/** + * Global OAuth refresh serialization, keyed by rotation group. + * + * Why this exists (Front 1 of the Codex multi-account cascade fix): + * Providers that share a single Auth0 client_id — notably OpenAI Codex and the + * `openai` provider — enforce "single active session per client_id". When two + * *sibling* accounts under that client refresh their `refresh_token` at nearly + * the same time, Auth0 treats it as token reuse and revokes the WHOLE + * refresh_token family, so previously-healthy accounts suddenly fail with + * `refresh_token_invalidated` / `refresh_token_reused` (openai/codex#9648). + * + * The per-connection mutex in tokenRefresh.ts does NOT help: the colliding + * refreshes happen on DIFFERENT connections. This serializer forces the actual + * network refresh to concurrency=1 across every connection in a rotation group, + * so two siblings never POST to /oauth/token concurrently. Optional spacing + * (CODEX_REFRESH_SPACING_MS) inserts a small gap between consecutive refreshes + * in a group for extra safety. Non-rotating providers (Google, etc.) are not + * serialized — their refresh_tokens are permanent and there is no cascade. + */ + +// Providers mapped to the same string share one serialized lane. Codex and the +// raw `openai` provider use the same Auth0 backend, so they MUST share a lane. +const ROTATION_LOCK_GROUP: Record = { + codex: "openai-auth0", + openai: "openai-auth0", + claude: "anthropic-oauth", + "gitlab-duo": "gitlab-duo", + kiro: "kiro", + "kimi-coding": "kimi-coding", + qwen: "qwen", +}; + +function readSpacingMs(): number { + const raw = Number(process.env.CODEX_REFRESH_SPACING_MS); + return Number.isFinite(raw) && raw >= 0 ? raw : 0; +} + +// Tail promise per group — each new refresh chains after the previous one. +const groupTail = new Map>(); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Returns the serialization group for a provider, or null when it is not a rotating provider. */ +export function rotationGroupFor(provider: string): string | null { + return ROTATION_LOCK_GROUP[provider] ?? null; +} + +/** + * Run `fn` (the actual network refresh) serialized against every other refresh + * in the same rotation group. Different groups run concurrently; non-rotating + * providers run immediately with no locking. + */ +export async function serializeRefresh(provider: string, fn: () => Promise): Promise { + const group = rotationGroupFor(provider); + if (!group) return fn(); + + const prevTail = groupTail.get(group) ?? Promise.resolve(); + let releaseMine!: () => void; + const mine = new Promise((resolve) => { + releaseMine = resolve; + }); + const myTail = prevTail.then(() => mine); + groupTail.set(group, myTail); + + // Wait for our turn. Ignore a predecessor's rejection — its `finally` still + // releases the lane, so the queue keeps flowing even after a failed refresh. + await prevTail.catch(() => {}); + + try { + return await fn(); + } finally { + const spacing = readSpacingMs(); + if (spacing > 0) await delay(spacing); + releaseMine(); + // Garbage-collect the lane when nobody chained after us. + if (groupTail.get(group) === myTail) groupTail.delete(group); + } +} + +/** + * Front 3 (reuse-race tolerance): decide whether an unrecoverable refresh failure + * (`refresh_token_invalidated` / `refresh_token_reused`) should be IGNORED because + * a concurrent or sibling refresh already rotated this connection's refresh_token. + * + * If the DB now holds a different, non-empty refresh_token than the one we + * presented, the failure was a stale-token reuse and the connection is actually + * healthy with the newer token — so it must stay active instead of being + * deactivated. Mirrors the health-check's `credentialsChangedSinceSweep` guard + * and codex-lb's replica race-detection. + */ +export function wasRefreshTokenRotated( + attemptedRefreshToken: string | null | undefined, + latestRefreshToken: string | null | undefined +): boolean { + return ( + typeof attemptedRefreshToken === "string" && + attemptedRefreshToken.length > 0 && + typeof latestRefreshToken === "string" && + latestRefreshToken.length > 0 && + latestRefreshToken !== attemptedRefreshToken + ); +} + +/** Test-only: clear all in-flight lanes between tests. */ +export function __resetRefreshSerializerForTest(): void { + groupTail.clear(); +} diff --git a/open-sse/services/sessionPool/fingerprintRotator.ts b/open-sse/services/sessionPool/fingerprintRotator.ts new file mode 100644 index 0000000000..bc3dc248c5 --- /dev/null +++ b/open-sse/services/sessionPool/fingerprintRotator.ts @@ -0,0 +1,130 @@ +/** + * FingerprintRotator — Browser fingerprint profiles for session diversity + * + * Each profile mimics a real browser user-agent + Sec-CH-UA headers. + * Rotating through these prevents providers from associating requests + * to a single browser identity for rate-limiting purposes. + */ + +import { type Fingerprint } from "./types.ts"; + +// ─── Profiles ────────────────────────────────────────────────────────────── + +const PROFILES: Fingerprint[] = [ + { + id: "chrome-mac", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + acceptLanguage: "en-US,en;q=0.9", + secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUaPlatform: '"macOS"', + secChUaMobile: "?0", + }, + { + id: "chrome-linux", + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + acceptLanguage: "en-US,en;q=0.9", + secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUaPlatform: '"Linux"', + secChUaMobile: "?0", + }, + { + id: "chrome-win", + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + acceptLanguage: "en-US,en;q=0.9", + secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Google Chrome";v="135"', + secChUaPlatform: '"Windows"', + secChUaMobile: "?0", + }, + { + id: "firefox-mac", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0", + acceptLanguage: "en-US,en;q=0.9", + }, + { + id: "firefox-linux", + userAgent: + "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0", + acceptLanguage: "en-US,en;q=0.9", + }, + { + id: "safari-mac", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15", + acceptLanguage: "en-US,en;q=0.9", + }, + { + id: "edge-mac", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", + acceptLanguage: "en-US,en;q=0.9", + secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Microsoft Edge";v="135"', + secChUaPlatform: '"macOS"', + secChUaMobile: "?0", + }, + { + id: "edge-win", + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", + acceptLanguage: "en-US,en;q=0.9", + secChUa: '"Not-A.Brand";v="99", "Chromium";v="135", "Microsoft Edge";v="135"', + secChUaPlatform: '"Windows"', + secChUaMobile: "?0", + }, +]; + +// ─── FingerprintRotator ──────────────────────────────────────────────────── + +export class FingerprintRotator { + private index = 0; + + /** Get the next profile in round-robin order */ + next(): Fingerprint { + const profile = PROFILES[this.index % PROFILES.length]; + this.index++; + return profile; + } + + /** Get a random profile */ + random(): Fingerprint { + return PROFILES[Math.floor(Math.random() * PROFILES.length)]; + } + + /** Reset the round-robin counter */ + reset(): void { + this.index = 0; + } + + /** Number of available profiles */ + get count(): number { + return PROFILES.length; + } + + /** Build a Headers object from a fingerprint (with optional extras) */ + buildHeaders( + fingerprint: Fingerprint, + extra?: Record, + ): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + "Accept-Language": fingerprint.acceptLanguage ?? "en-US,en;q=0.9", + "User-Agent": fingerprint.userAgent, + ...extra, + }; + if (fingerprint.secChUa) { + headers["Sec-CH-UA"] = fingerprint.secChUa; + headers["Sec-CH-UA-Mobile"] = fingerprint.secChUaMobile ?? "?0"; + headers["Sec-CH-UA-Platform"] = fingerprint.secChUaPlatform ?? '"Windows"'; + } + return headers; + } + + /** List all profiles */ + listAll(): Fingerprint[] { + return [...PROFILES]; + } +} diff --git a/open-sse/services/sessionPool/index.ts b/open-sse/services/sessionPool/index.ts new file mode 100644 index 0000000000..ab349a2c66 --- /dev/null +++ b/open-sse/services/sessionPool/index.ts @@ -0,0 +1,28 @@ +/** + * Session Pool — Barrel exports + * + * Usage: + * import { SessionPool, Session, FingerprintRotator, SessionFactory, withSessionPool } from "./sessionPool/index.ts"; + * import type { PoolConfig, PoolStats, PoolSessionDetail } from "./sessionPool/types.ts"; + */ + +export { Session } from "./session.ts"; +export { SessionPool } from "./sessionPool.ts"; +export { SessionFactory } from "./sessionFactory.ts"; +export { FingerprintRotator } from "./fingerprintRotator.ts"; +export { withSessionPool } from "./webExecutorWrapper.ts"; +export { PoolRegistry } from "./poolRegistry.ts"; + +export type { + Fingerprint, + SessionState, + SessionResult, + PoolConfig, + PoolStats, + PoolSessionDetail, + WebExecutorFn, + WebExecutorRequest, + WebExecutorResponse, +} from "./types.ts"; + +export { DEFAULT_POOL_CONFIG } from "./types.ts"; diff --git a/open-sse/services/sessionPool/poolRegistry.ts b/open-sse/services/sessionPool/poolRegistry.ts new file mode 100644 index 0000000000..53cb26e55a --- /dev/null +++ b/open-sse/services/sessionPool/poolRegistry.ts @@ -0,0 +1,93 @@ +/** + * Pool Registry — Global registry for active session pools. + * + * Provides a rendezvous point between executors (which create pools) + * and MCP tools / API handlers (which query pool state). + * + * Usage (executor side): + * PoolRegistry.register("pollinations", myPool); + * + * Usage (MCP tool / API side): + * const stats = PoolRegistry.getStats("pollinations"); + * const all = PoolRegistry.getAllStats(); + */ + +import type { SessionPool } from "./sessionPool.ts"; +import type { PoolStats, PoolSessionDetail } from "./types.ts"; + +type PoolEntry = { + pool: SessionPool; + createdAt: number; +}; + +class PoolRegistryImpl { + private pools = new Map(); + + /** Register a pool for a provider. Overwrites any previous pool. */ + register(provider: string, pool: SessionPool): void { + this.pools.set(provider, { pool, createdAt: Date.now() }); + } + + /** Unregister a pool */ + unregister(provider: string): boolean { + return this.pools.delete(provider); + } + + /** Get a pool by provider name */ + getPool(provider: string): SessionPool | undefined { + return this.pools.get(provider)?.pool; + } + + /** List all registered provider names */ + listProviders(): string[] { + return Array.from(this.pools.keys()); + } + + /** Get stats for a specific provider's pool */ + getStats(provider: string): (PoolStats & { createdAt: number }) | null { + const entry = this.pools.get(provider); + if (!entry) return null; + return { ...entry.pool.getStats(), createdAt: entry.createdAt }; + } + + /** Get stats for all registered pools */ + getAllStats(): Array { + const result: Array = []; + for (const [, entry] of this.pools) { + result.push({ ...entry.pool.getStats(), createdAt: entry.createdAt }); + } + return result; + } + + /** Get per-session details for a specific provider */ + getSessionDetails(provider: string): PoolSessionDetail[] | null { + const entry = this.pools.get(provider); + if (!entry) return null; + return entry.pool.getSessionDetails(); + } + + /** Reset (shutdown + recreate) a pool */ + resetPool(provider: string): boolean { + const entry = this.pools.get(provider); + if (!entry) return false; + entry.pool.shutdown(); + this.pools.delete(provider); + return true; + } + + /** Warm up a pool to a target session count */ + async warmPool(provider: string, count: number): Promise { + const entry = this.pools.get(provider); + if (!entry) return false; + await entry.pool.warmUp(count); + return true; + } + + /** Count of registered pools */ + get size(): number { + return this.pools.size; + } +} + +/** Singleton global pool registry */ +export const PoolRegistry = new PoolRegistryImpl(); diff --git a/open-sse/services/sessionPool/session.ts b/open-sse/services/sessionPool/session.ts new file mode 100644 index 0000000000..b596e5057b --- /dev/null +++ b/open-sse/services/sessionPool/session.ts @@ -0,0 +1,119 @@ +/** + * Session — State machine for one pool session + * + * Lifecycle: + * active ──(429)──▶ cooldown ──(timeout)──▶ active + * active ──(5xx)──▶ dead ──(pruned)──▶ removed + * active ──(TTL)──▶ replaced with fresh session + * + * Each session tracks: + * - Fingerprint (UA + headers for one browser identity) + * - Request metrics (total, success, fail, inflight) + * - Cooldown state (exponential backoff on rate limits) + */ + +import { type Fingerprint, type SessionStatus } from "./types.ts"; + +export class Session { + readonly id: string; + readonly fingerprint: Fingerprint; + readonly createdAt: number; + + status: SessionStatus = "active"; + inflight = 0; + totalRequests = 0; + successfulRequests = 0; + failedRequests = 0; + consecutiveFails = 0; + cooldownUntil = 0; + lastUsedAt = 0; + + constructor( + fingerprint: Fingerprint, + private readonly cooldownBase: number, + private readonly cooldownMax: number, + private readonly cooldownJitter: number, + ) { + this.id = `sess-${fingerprint.id}-${Date.now().toString(36)}`; + this.fingerprint = fingerprint; + this.createdAt = Date.now(); + } + + /** Whether this session can accept requests right now */ + get isAvailable(): boolean { + if (this.status === "dead") return false; + if (this.status === "cooldown") { + if (Date.now() >= this.cooldownUntil) { + // Auto-recover from cooldown + this.status = "active"; + this.consecutiveFails = 0; + return true; + } + return false; + } + return true; + } + + /** Mark a successful request */ + markSuccess(): void { + this.successfulRequests++; + this.consecutiveFails = 0; + } + + /** Enter cooldown with exponential backoff */ + markCooldown(): void { + this.consecutiveFails++; + const base = Math.min( + this.cooldownBase * Math.pow(2, this.consecutiveFails - 1), + this.cooldownMax, + ); + const jitter = Math.random() * this.cooldownJitter; + this.cooldownUntil = Date.now() + base + jitter; + this.status = "cooldown"; + } + + /** Mark session as dead (non-recoverable error) */ + markDead(): void { + this.status = "dead"; + } + + /** Increment inflight counter and mark as used */ + acquire(): void { + this.inflight++; + this.totalRequests++; + this.lastUsedAt = Date.now(); + } + + /** Decrement inflight counter */ + release(): void { + this.inflight = Math.max(0, this.inflight - 1); + } + + /** Milliseconds remaining in cooldown */ + get cooldownRemaining(): number { + if (this.status !== "cooldown") return 0; + return Math.max(0, this.cooldownUntil - Date.now()); + } + + /** Age in milliseconds */ + get age(): number { + return Date.now() - this.createdAt; + } + + /** Build headers for this session's fingerprint */ + buildHeaders(extra?: Record): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + "Accept-Language": this.fingerprint.acceptLanguage ?? "en-US,en;q=0.9", + "User-Agent": this.fingerprint.userAgent, + ...extra, + }; + if (this.fingerprint.secChUa) { + headers["Sec-CH-UA"] = this.fingerprint.secChUa; + headers["Sec-CH-UA-Mobile"] = this.fingerprint.secChUaMobile ?? "?0"; + headers["Sec-CH-UA-Platform"] = this.fingerprint.secChUaPlatform ?? '"Windows"'; + } + return headers; + } +} diff --git a/open-sse/services/sessionPool/sessionFactory.ts b/open-sse/services/sessionPool/sessionFactory.ts new file mode 100644 index 0000000000..39c1c2e596 --- /dev/null +++ b/open-sse/services/sessionPool/sessionFactory.ts @@ -0,0 +1,57 @@ +/** + * SessionFactory — Creates initialized Session instances + * + * For zero-auth providers (Pollinations, Puter): just assigns a fingerprint. + * For cookie-based providers (ChatGPT Web, DeepSeek Web): would launch + * headless Playwright, solve Turnstile, and extract cookies. + * + * Currently only zero-auth is implemented. Cookie-based provider support + * is planned for Phase 3. + */ + +import { FingerprintRotator } from "./fingerprintRotator.ts"; +import { Session } from "./session.ts"; +import type { PoolConfig } from "./types.ts"; + +export class SessionFactory { + private rotator = new FingerprintRotator(); + + constructor(private config: PoolConfig) {} + + /** + * Create a new session with the next available fingerprint. + * For zero-auth providers, this is a lightweight operation + * (just picks a fingerprint). For cookie-based providers this + * would involve Playwright browser automation. + */ + createSession(): Session { + // Round-robin so each session in a warm pool gets a distinct fingerprint + // (a pool of look-alike sessions defeats the purpose). The rotator still + // exposes random() for callers that want unpredictability over time. + const fingerprint = this.rotator.next(); + return new Session( + fingerprint, + this.config.cooldownBase, + this.config.cooldownMax, + this.config.cooldownJitter, + ); + } + + /** Reset the fingerprint rotator (e.g., after config change) */ + resetRotator(): void { + this.rotator.reset(); + } + + /** Number of available fingerprint profiles */ + get profileCount(): number { + return this.rotator.count; + } + + /** Build headers from session fingerprint */ + buildHeaders( + session: Session, + extra?: Record, + ): Record { + return session.buildHeaders(extra); + } +} diff --git a/open-sse/services/sessionPool/sessionPool.ts b/open-sse/services/sessionPool/sessionPool.ts new file mode 100644 index 0000000000..184c792cea --- /dev/null +++ b/open-sse/services/sessionPool/sessionPool.ts @@ -0,0 +1,313 @@ +/** + * SessionPool — Pool manager for anonymous web sessions + * + * Manages N sessions across N browser fingerprints, distributing requests + * round-robin to avoid rate-limiting any single "browser identity." + * + * Key behaviors: + * - acquire(): Returns the next available session (round-robin) + * - reportSuccess(session): Updates metrics, marks session healthy + * - reportFailure(session, status): Cooldown on 429, dead on 5xx + * - Scalability: Round-robin across sessions prevents one session from + * being overused while others sit idle. + * - Auto-heal: Sessions in cooldown auto-recover after the backoff window. + * - Self-repair: Dead sessions are replaced on next acquire if below maxSessions. + * - Thread-safe for concurrent request patterns (each acquire/release pair + * is atomic within one async flow). + * + * Usage: + * const pool = new SessionPool("pollinations", poolConfig, factory); + * await pool.ensureMinSessions(); + * const session = pool.acquire(); + * try { + * const res = await fetch(url, { headers: session.buildHeaders() }); + * if (!res.ok && res.status === 429) pool.reportCooldown(session); + * else pool.reportSuccess(session); + * } catch { pool.reportDead(session); } + * finally { session.release(); } + */ + +import { type EventEmitter } from "node:events"; +import { createHash, randomUUID } from "node:crypto"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { Session } from "./session.ts"; +import { SessionFactory } from "./sessionFactory.ts"; +import { + type PoolConfig, + type PoolSessionDetail, + type PoolStats, + DEFAULT_POOL_CONFIG, +} from "./types.ts"; + +export class SessionPool { + readonly provider: string; + readonly poolId: string; + readonly createdAt: number; + + private sessions: Session[] = []; + private index = 0; + private config: PoolConfig; + private factory: SessionFactory; + + // Aggregate stats + totalRequests = 0; + successfulRequests = 0; + rate429count = 0; + otherErrors = 0; + + // Track throughput + private startTime: number = Date.now(); + private lastLog = 0; + + constructor( + provider: string, + config?: Partial, + factory?: SessionFactory, + ) { + this.provider = provider; + this.poolId = `pool-${provider}-${Date.now().toString(36)}`; + this.createdAt = Date.now(); + this.config = { ...DEFAULT_POOL_CONFIG, ...config }; + this.factory = factory ?? new SessionFactory(this.config); + } + + // ─── Pool Lifecycle ────────────────────────────────────────────────── + + /** Ensure the pool has at least minSessions ready */ + async ensureMinSessions(): Promise { + const needed = this.config.minSessions - this.sessions.length; + if (needed <= 0) return; + + const promises: Promise[] = []; + for (let i = 0; i < needed; i++) { + promises.push(Promise.resolve(this.createSession())); + } + await Promise.allSettled(promises); + } + + /** Warm up the pool to a specific size (bypasses minSessions limit) */ + async warmUp(count: number): Promise { + const target = Math.min(count, this.config.maxSessions); + const needed = target - this.sessions.length; + if (needed <= 0) return; + + const promises: Promise[] = []; + for (let i = 0; i < needed; i++) { + promises.push(Promise.resolve(this.createSession())); + } + await Promise.allSettled(promises); + } + + /** Graceful shutdown — mark all sessions dead */ + async shutdown(): Promise { + for (const s of this.sessions) { + s.markDead(); + } + this.sessions = []; + } + + // ─── Acquire / Release ─────────────────────────────────────────────── + + /** + * Acquire the next available session (round-robin with availability check). + * Returns null if no sessions are available (all on cooldown/dead). + */ + acquire(): Session | null { + // First pass: try round-robin from current index + if (this.sessions.length === 0) return null; + + const startIdx = this.index % this.sessions.length; + + for (let i = 0; i < this.sessions.length; i++) { + const idx = (startIdx + i) % this.sessions.length; + const session = this.sessions[idx]; + if (session.isAvailable) { + // Skip sessions that hit max inflight limit (safety valve) + // For anonymous web providers we allow high concurrency per session + this.index = (idx + 1) % this.sessions.length; + session.acquire(); + this.totalRequests++; + return session; + } + } + + // No ready sessions — try to create a new one if under max + if (this.sessions.length < this.config.maxSessions) { + const session = this.createSession(); + session.acquire(); + this.totalRequests++; + return session; + } + + // Last resort: wait for the nearest cooldown to expire (caller should retry) + return null; + } + + /** + * Report a successful request. Updates metrics pool-wide and per-session. + */ + reportSuccess(session: Session): void { + session.markSuccess(); + this.successfulRequests++; + } + + /** + * Report a rate-limit (429). Puts the session into exponential-backoff cooldown. + */ + reportCooldown(session: Session): void { + session.markCooldown(); + this.rate429count++; + this.maybeLog(); + } + + /** + * Report a non-recoverable error. Marks session as dead. + */ + reportDead(session: Session): void { + session.markDead(); + this.otherErrors++; + } + + // ─── Health / Stats ────────────────────────────────────────────────── + + /** Count of available (active, not in cooldown) sessions */ + get availableCount(): number { + return this.sessions.filter((s) => s.isAvailable).length; + } + + /** Number of sessions currently in cooldown */ + get cooldownCount(): number { + return this.sessions.filter((s) => s.status === "cooldown").length; + } + + /** Number of dead sessions */ + get deadCount(): number { + return this.sessions.filter((s) => s.status === "dead").length; + } + + /** Total sessions managed */ + get totalCount(): number { + return this.sessions.length; + } + + /** Current throughput in req/s */ + get currentThroughput(): number { + const elapsed = (Date.now() - this.startTime) / 1000; + return elapsed > 0 ? this.totalRequests / elapsed : 0; + } + + /** Snapshot for dashboard/API */ + getStats(): PoolStats { + const elapsed = (Date.now() - this.startTime) / 1000; + return { + provider: this.provider, + sessions: { + total: this.sessions.length, + active: this.availableCount, + cooldown: this.cooldownCount, + dead: this.deadCount, + }, + requests: { + total: this.totalRequests, + success: this.successfulRequests, + rate429: this.rate429count, + otherErrors: this.otherErrors, + }, + throughput: this.currentThroughput.toFixed(1), + successRate: + this.totalRequests > 0 + ? ((this.successfulRequests / this.totalRequests) * 100).toFixed(1) + : "100.0", + elapsed: elapsed.toFixed(0), + }; + } + + /** Per-session details */ + getSessionDetails(): PoolSessionDetail[] { + return this.sessions.map((s) => ({ + id: s.id, + fingerprint: s.fingerprint.id, + status: s.status, + totalRequests: s.totalRequests, + successfulRequests: s.successfulRequests, + successRate: + s.totalRequests > 0 + ? ((s.successfulRequests / s.totalRequests) * 100).toFixed(1) + : "100.0", + inflight: s.inflight, + cooldownRemaining: s.cooldownRemaining > 0 + ? `${(s.cooldownRemaining / 1000).toFixed(1)}s` + : "0s", + age: `${(s.age / 1000).toFixed(0)}s`, + })); + } + + /** As acquire(), but blocks until a session is available */ + async acquireBlocking(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + + // Fast path + const fast = this.acquire(); + if (fast) return fast; + + // Wait-loop with backoff (50ms → 200ms) + let delay = 50; + while (Date.now() < deadline) { + await sleep(delay); + const session = this.acquire(); + if (session) return session; + delay = Math.min(delay * 2, 200); + } + + throw new Error( + `[SessionPool:${this.provider}] No session available after ${timeoutMs}ms timeout`, + ); + } + + /** As acquireBlocking(), but accepts arbitrary function to wrap */ + async executeWithSession( + fn: (session: Session) => Promise, + timeoutMs = 10_000, + ): Promise { + const session = await this.acquireBlocking(timeoutMs); + try { + const result = await fn(session); + return result; + } finally { + session.release(); + } + } + + // ─── Internal ──────────────────────────────────────────────────────── + + /** Create and register a new session */ + private createSession(): Session { + const session = this.factory.createSession(); + this.sessions.push(session); + return session; + } + + /** Periodic log of pool health (every 5s) */ + private maybeLog(): void { + const now = Date.now(); + if (now - this.lastLog < 5_000) return; + this.lastLog = now; + + const stats = this.getStats(); + if (stats.requests.total % 50 === 0) { + // Rate-limited to avoid log spam + } + } + + /** Remove dead sessions (call periodically for reclamation */ + pruneDeadSessions(): void { + const before = this.sessions.length; + this.sessions = this.sessions.filter((s) => s.status !== "dead"); + + // If we pruned sessions, report + if (this.sessions.length < before && this.sessions.length < this.config.minSessions) { + this.ensureMinSessions(); + } + } +} diff --git a/open-sse/services/sessionPool/types.ts b/open-sse/services/sessionPool/types.ts new file mode 100644 index 0000000000..6a2966a4f6 --- /dev/null +++ b/open-sse/services/sessionPool/types.ts @@ -0,0 +1,100 @@ +/** + * Session Pool — Type Definitions + * + * Core types for the anonymous session pool system: + * SessionPool → manages N sessions, each with a unique browser fingerprint + * Fingerprint → UA + headers for one browser-like identity + * Session → state machine tracking one session through its lifecycle + * PoolConfig → hot-reloadable pool parameters + * PoolStats → real-time pool health snapshot + */ + +// ─── Fingerprint Types ───────────────────────────────────────────────────── + +export interface Fingerprint { + id: string; + userAgent: string; + acceptLanguage: string; + secChUa?: string; + secChUaPlatform?: string; + secChUaMobile?: string; +} + +export type FingerprintProfile = Fingerprint; + +// ─── Session Types ───────────────────────────────────────────────────────── + +export type SessionStatus = "active" | "cooldown" | "dead"; + +export interface SessionState { + id: string; + fingerprint: Fingerprint; + status: SessionStatus; + inflight: number; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + consecutiveFails: number; + cooldownUntil: number; + createdAt: number; + lastUsedAt: number; +} + +export interface SessionResult { + status: "ok" | "rate_limited" | "dead" | "error"; +} + +// ─── Pool Types ──────────────────────────────────────────────────────────── + +export interface PoolConfig { + minSessions: number; + maxSessions: number; + cooldownBase: number; // ms, default 1000 + cooldownMax: number; // ms, default 30000 + cooldownJitter: number; // ms, default 5000 + requestTimeout: number; // ms, default 30000 + requestJitter: number; // ms, default 50 +} + +export interface PoolStats { + provider: string; + sessions: { + total: number; + active: number; + cooldown: number; + dead: number; + }; + requests: { + total: number; + success: number; + rate429: number; + otherErrors: number; + }; + throughput: string; // req/s + successRate: string; // percentage + elapsed: string; +} + +export interface PoolSessionDetail { + id: string; + fingerprint: string; + status: SessionStatus; + totalRequests: number; + successfulRequests: number; + successRate: string; + inflight: number; + cooldownRemaining: string; + age: string; +} + +// ─── Defaults ────────────────────────────────────────────────────────────── + +export const DEFAULT_POOL_CONFIG: PoolConfig = { + minSessions: 6, + maxSessions: 20, + cooldownBase: 1_000, + cooldownMax: 30_000, + cooldownJitter: 5_000, + requestTimeout: 30_000, + requestJitter: 50, +}; diff --git a/open-sse/services/sessionPool/webExecutorWrapper.ts b/open-sse/services/sessionPool/webExecutorWrapper.ts new file mode 100644 index 0000000000..cb51048dcd --- /dev/null +++ b/open-sse/services/sessionPool/webExecutorWrapper.ts @@ -0,0 +1,132 @@ +/** + * WebExecutorWrapper — Wraps any web executor with session pool support + * + * This is the integration bridge between the session pool and OmniRoute's + * executor system. It intercepts the fetch() call to add session-pool + * headers (fingerprint-based User-Agent, Sec-CH-UA, etc.) and handles + * 429/5xx responses with pool-level cooldown management. + * + * Future: For cookie-based providers (ChatGPT Web, DeepSeek Web, etc.) + * the wrapper will also inject cookies from the Playwright-authenticated + * session. + */ + +import { Session } from "./session.ts"; +import { SessionPool } from "./sessionPool.ts"; + +export interface WebExecutorRequest { + url: string; + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + [key: string]: unknown; +} + +export interface WebExecutorResponse { + status: number; + statusText?: string; + headers?: Record; + body: string; + ok: boolean; + [key: string]: unknown; +} + +export interface WebExecutorFn { + (req: WebExecutorRequest): Promise; +} + +/** + * Decorate a web executor function with session pool support. + * + * Before the underlying executor fires: + * 1. Acquires a session from the pool (reusable, fingerprint-isolated) + * 2. Merges session headers (UA + Sec-CH-UA) into the request + * 3. Handles 429 → pool cooldown, 5xx → session death + * + * For zero-auth providers like Pollinations, Puter, etc. this is all + * that's needed for "truly unlimited" — the fingerprint rotation alone + * defeats burst-based rate limiting. + */ +export function withSessionPool( + executor: WebExecutorFn, + pool: SessionPool, + options?: { + /** When true, wraps the response body for error handling */ + wrapResponse?: boolean; + }, +): WebExecutorFn { + const wrapResponse = options?.wrapResponse ?? true; + + return async (req: WebExecutorRequest): Promise => { + // Acquire session from pool (blocking with backoff) + let session: Session | null = null; + try { + session = await pool.acquireBlocking(); + } catch (err) { + return { + status: 503, + statusText: "Service Unavailable", + body: JSON.stringify({ + error: "session_pool_exhausted", + message: `[SessionPool:${pool.provider}] ${(err as Error).message}`, + }), + ok: false, + headers: {}, + }; + } + + try { + // Build request with session fingerprint headers + const sessionHeaders = session.buildHeaders(req.headers); + const poolReq: WebExecutorRequest = { + ...req, + headers: sessionHeaders, + }; + + // Execute the underlying web request + const res = await executor(poolReq); + + // Handle response status + if (res.status === 429) { + pool.reportCooldown(session); + + if (wrapResponse) { + return { + ...res, + body: JSON.stringify({ + error: "pool_rate_limited", + message: `[SessionPool:${pool.provider}] Rate limited, session ${session.id} in cooldown`, + }), + }; + } + return res; + } + + if (res.status >= 500) { + pool.reportDead(session); + return res; + } + + // Success + pool.reportSuccess(session); + pool.totalRequests++; + return res; + } catch (err) { + // Network error — cooldown, not dead (may be transient) + pool.reportCooldown(session); + return { + status: 502, + statusText: "Bad Gateway", + body: JSON.stringify({ + error: "pool_network_error", + message: (err as Error).message, + }), + ok: false, + headers: {}, + }; + } finally { + session.release(); + } + }; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index ae7ea2e6a1..4abb7acbee 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -4,6 +4,7 @@ import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts"; import { pbkdf2Sync } from "node:crypto"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; +import { serializeRefresh } from "./refreshSerializer.ts"; import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth"; import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; @@ -1500,7 +1501,9 @@ export async function getAccessToken( // the legacy `connectionId`-less path would silently swallow the callback, // leaving DB rows out of sync with rotated tokens (Codex/OpenAI). We still // resolve the promise to all waiters with the refreshed credentials. - const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig) + const refreshPromise = serializeRefresh(provider, () => + _getAccessTokenInternal(provider, credentials, log, proxyConfig) + ) .then(async (result) => { if (result?.accessToken && effectiveOnPersist) { try { @@ -1604,7 +1607,12 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro } const oldRefreshToken = credentials.refreshToken; - const result = await _getAccessTokenInternal(provider, credentials, log, proxyConfig); + // Front 1: serialize the network refresh across all connections of the same + // rotation group (e.g. Codex+openai share one Auth0 client) so two sibling + // accounts never refresh concurrently and trip Auth0 family revocation. + const result = await serializeRefresh(provider, () => + _getAccessTokenInternal(provider, credentials, log, proxyConfig) + ); // Record the rotation so subsequent stale callers can be redirected to the // new tokens without re-hitting upstream (which would trigger Auth0 family diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 1f3cade0f6..3f404b448d 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1821,6 +1821,28 @@ const _antigravityAvailableModelsInflight = new Map>(); const _antigravityCreditProbeCache = new Map(); const _antigravityCreditProbeInflight = new Map>(); +// ── Proactive TTL purging for module-level caches ────────────────────────── +// All 4 data caches only evict on read (passive TTL). This interval proactively +// purges stale entries so keys accessed once and never again don't leak memory. +// The 2 inflight Maps (availableModelsInflight, creditProbeInflight) self-clean +// when the Promise resolves/rejects, so they are NOT touched here. +const _usageCacheCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of _geminiCliSubCache) { + if (now - entry.fetchedAt > GEMINI_CLI_CACHE_TTL_MS) _geminiCliSubCache.delete(key); + } + for (const [key, entry] of _antigravitySubCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_CACHE_TTL_MS) _antigravitySubCache.delete(key); + } + for (const [key, entry] of _antigravityAvailableModelsCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS) _antigravityAvailableModelsCache.delete(key); + } + for (const [key, entry] of _antigravityCreditProbeCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_CREDIT_PROBE_TTL_MS) _antigravityCreditProbeCache.delete(key); + } +}, 5 * 60 * 1000); // every 5 minutes +_usageCacheCleanupTimer.unref?.(); // Don't prevent process exit + interface AntigravityUsageOptions { forceRefresh?: boolean; } @@ -2917,4 +2939,17 @@ export const __testing = { getMiniMaxPlanLabel, inferMiniMaxPlanLabelFromTotals, getOpencodeUsage, + getClaudePlanLabel, + createQuotaFromUsage, + getMiniMaxQuotaResetAt, + isMiniMaxTextQuotaModel, + getMiniMaxSessionTotal, + getMiniMaxWeeklyTotal, + createMiniMaxQuotaFromCount, + getMiniMaxAuthErrorMessage, + getMiniMaxErrorSummary, + mapCodeAssistSubscriptionToPlanLabel, + mapCodeAssistTierIdToLabel, + mapSubscriptionTierStringToPlanLabel, + toDisplayLabel, }; diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index 62f3f06ca5..61fe2355c3 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -130,6 +130,7 @@ function buildFallbackTool(tool: JsonRecord, targetFormat?: string | null): Json } export function supportsNativeWebSearchFallbackBypass({ + sourceFormat, targetFormat, nativeCodexPassthrough, }: { @@ -138,8 +139,16 @@ export function supportsNativeWebSearchFallbackBypass({ targetFormat: string | null | undefined; nativeCodexPassthrough: boolean; }): boolean { + // Native Codex (OpenAI Responses) passthrough: the upstream runs web search itself. if (nativeCodexPassthrough) return true; - return targetFormat === FORMATS.GEMINI; + // Gemini target: the Gemini translator maps built-in web search to googleSearch natively. + if (targetFormat === FORMATS.GEMINI) return true; + // Claude -> Claude passthrough: the Anthropic Messages upstream (e.g. a Claude + // subscription driven by Claude Code) natively runs web_search_20250305. Forward the + // native tool untouched instead of rewriting it to omniroute_web_search. Mirrors the + // Codex/Gemini bypasses so every native-web-search provider is treated symmetrically. + if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) return true; + return false; } export function prepareWebSearchFallbackBody( diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index eb47cb4483..28f9b27cf5 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -232,3 +232,156 @@ export function injectEmptyReasoningContentForToolCalls( return { ...message, reasoning_content: "" }; }); } + +/** + * Anthropic's first-party Messages API strictly validates tool `input_schema` + * against JSON Schema draft 2020-12. IDE/SDK agent harnesses that deep-truncate + * their schemas emit invalid constructs — most commonly an array keyword + * (`enum`, `required`, …) replaced by a placeholder string such as + * `"[MaxDepth]"`, or an index-keyed object (`{"0":"a","1":"b"}`) where an array + * is expected. Anthropic rejects these with + * `tools.N.custom.input_schema: JSON schema is invalid` (surfaced as a + * misleading `400 out of extra usage` placeholder when streaming). Non-Anthropic + * targets (OpenAI/Codex) tolerate them, which is why the same request succeeds + * on a fallback provider. This sanitizer coerces or drops the invalid + * constructs so legitimate native-Claude-OAuth traffic is not spuriously + * rejected. See Spec E (Claude Code OAuth wire compatibility). + */ +const SCHEMA_PLACEHOLDER_PATTERN = /^\[(?:MaxDepth|Truncated|Circular|Object|Array)\]$/; +const ARRAY_SCHEMA_KEYS = ["enum", "required", "anyOf", "oneOf", "allOf", "prefixItems"]; +const SCHEMA_ARRAY_OF_SCHEMAS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]); +const SCHEMA_SLOT_KEYS = [ + "items", + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedProperties", + "additionalItems", +]; + +function coerceIndexedObjectToArray(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value; + if (isPlainObject(value)) { + const keys = Object.keys(value); + if (keys.length > 0 && keys.every((key, index) => String(index) === key)) { + return keys.map((key) => value[key]); + } + } + return null; +} + +function isSchemaPlaceholder(value: unknown): boolean { + return typeof value === "string" && SCHEMA_PLACEHOLDER_PATTERN.test(value.trim()); +} + +export function stripInvalidSchemaConstructs(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripInvalidSchemaConstructs(entry)); + } + if (!isPlainObject(schema)) { + return isSchemaPlaceholder(schema) ? {} : schema; + } + + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(schema)) { + // Coerce string-encoded numeric constraints (e.g. minimum: "5") to numbers — + // Anthropic rejects the string form. Done here so the Claude sanitizer covers + // every slot this function recurses into (incl. contains / propertyNames / + // additionalItems, which coerceSchemaNumericFields does not visit). + if ((NUMERIC_SCHEMA_FIELDS as readonly string[]).includes(key)) { + result[key] = coerceNumericString(value); + continue; + } + if (ARRAY_SCHEMA_KEYS.includes(key)) { + const array = coerceIndexedObjectToArray(value); + if (array === null) continue; // drop invalid non-array keyword (e.g. enum: "[MaxDepth]") + result[key] = SCHEMA_ARRAY_OF_SCHEMAS.has(key) + ? array.map((entry) => stripInvalidSchemaConstructs(entry)) + : array; + continue; + } + if (SCHEMA_SLOT_KEYS.includes(key)) { + // Boolean schemas are valid in JSON Schema (e.g. `additionalProperties: false` + // locks down the object); coercing to {} would silently allow extras and + // invite the model to hallucinate arguments. Only placeholder strings + // (e.g. "[MaxDepth]") get replaced with the permissive {}. + if (isPlainObject(value) || Array.isArray(value)) { + result[key] = stripInvalidSchemaConstructs(value); + } else if (typeof value === "boolean") { + result[key] = value; + } else if (isSchemaPlaceholder(value)) { + result[key] = {}; + } else { + result[key] = value; + } + continue; + } + if (key === "const") { + if (isSchemaPlaceholder(value)) continue; + result[key] = value; + continue; + } + if (key === "properties" && isPlainObject(value)) { + const properties: JsonRecord = {}; + for (const [propName, propSchema] of Object.entries(value)) { + // Same boolean-preservation rule as SCHEMA_SLOT_KEYS above: + // `{ properties: { onlyAdminCanSet: false } }` is a valid permission + // gate and must not be silently turned into the permissive {}. + if (isPlainObject(propSchema) || Array.isArray(propSchema)) { + properties[propName] = stripInvalidSchemaConstructs(propSchema); + } else if (typeof propSchema === "boolean") { + properties[propName] = propSchema; + } else if (isSchemaPlaceholder(propSchema)) { + properties[propName] = {}; + } else { + properties[propName] = propSchema; + } + } + result[key] = properties; + continue; + } + if ( + (key === "$defs" || + key === "definitions" || + key === "patternProperties" || + key === "dependentSchemas") && + isPlainObject(value) + ) { + const defs: JsonRecord = {}; + for (const [defName, defSchema] of Object.entries(value)) { + defs[defName] = stripInvalidSchemaConstructs(defSchema); + } + result[key] = defs; + continue; + } + // Placeholders are only coerced to {} in subschema-expecting positions + // (handled in the branches above). A placeholder in a scalar annotation + // keyword (description / title / pattern / format) must stay scalar — + // turning it into {} is itself invalid draft-2020-12 and would re-trigger + // the very 400 this sanitizer prevents. + result[key] = + isPlainObject(value) || Array.isArray(value) ? stripInvalidSchemaConstructs(value) : value; + } + return result; +} + +export function sanitizeClaudeToolSchema(schema: unknown): unknown { + // stripInvalidSchemaConstructs now also coerces numeric-string constraints, so + // it is the single pass for the Claude path. We deliberately do NOT compose + // coerceSchemaNumericFields: it strips the valid `default` keyword (Fix #1782, + // a translator concern) which on the native / passthrough surface would + // silently alter tool schemas that were previously forwarded verbatim. + return stripInvalidSchemaConstructs(schema); +} + +export function sanitizeClaudeToolSchemas(tools: unknown): unknown { + if (!Array.isArray(tools)) return tools; + return tools.map((tool) => { + if (!isPlainObject(tool) || tool.input_schema === undefined) return tool; + return { ...tool, input_schema: sanitizeClaudeToolSchema(tool.input_schema) }; + }); +} diff --git a/open-sse/translator/helpers/toolCallShim.ts b/open-sse/translator/helpers/toolCallShim.ts index 94ebfece01..cc92174386 100644 --- a/open-sse/translator/helpers/toolCallShim.ts +++ b/open-sse/translator/helpers/toolCallShim.ts @@ -28,6 +28,17 @@ function coerceToArray(v: unknown): unknown[] { } const TOOL_SHIMS: Record = { + // Claude Code Read accepts `pages` only for PDFs and rejects an empty string. + // Some non-Anthropic models emit optional `pages: ""` for ordinary files. + // Buffer and emit one cleaned JSON delta so the client never sees the bad field. + Read: (input) => { + if (typeof input !== "object" || input === null || Array.isArray(input)) return input; + const patched = { ...(input as Record) }; + if (patched.pages === "" || (Array.isArray(patched.pages) && patched.pages.length === 0)) { + delete patched.pages; + } + return patched; + }, submit_pr_review: (input) => { if (typeof input !== "object" || input === null || Array.isArray(input)) return input; const patched = { ...(input as Record) }; diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 123221eb84..65341d5393 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -183,12 +183,15 @@ export function translateRequest( const toOpenAI = getRequestTranslator(sourceFormat, FORMATS.OPENAI); if (toOpenAI) { // Forward Copilot UA marker to source→openai translators only. - const step1Credentials = options?.copilotClient - ? { - ...(credentials && typeof credentials === "object" ? credentials : {}), - _copilotClient: true, - } - : credentials; + const hasTargetHint = targetFormat != null; + const step1Credentials = + options?.copilotClient || hasTargetHint + ? { + ...(credentials && typeof credentials === "object" ? credentials : {}), + ...(options?.copilotClient ? { _copilotClient: true } : {}), + ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), + } + : credentials; result = toOpenAI(model, result, stream, step1Credentials); // Log OpenAI intermediate format reqLogger?.logOpenAIRequest?.(result); diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index fdf6687512..f426c99857 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -27,8 +27,55 @@ function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined { return normalized || undefined; } +function isClaudeServerWebSearchTool(tool: unknown): tool is JsonRecord { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const record = tool as JsonRecord; + return ( + record.name === "web_search" && + typeof record.type === "string" && + /^web_search_\d{8}$/.test(record.type) + ); +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function convertClaudeServerWebSearchTool(tool: JsonRecord): JsonRecord { + const allowedDomains = toStringArray(tool.allowed_domains); + const blockedDomains = toStringArray(tool.blocked_domains); + const filters: JsonRecord = {}; + if (allowedDomains.length > 0) filters.allowed_domains = allowedDomains; + if (blockedDomains.length > 0) filters.blocked_domains = blockedDomains; + + return { + type: "web_search", + ...(Object.keys(filters).length > 0 ? { filters } : {}), + ...(tool.user_location && typeof tool.user_location === "object" && !Array.isArray(tool.user_location) + ? { user_location: tool.user_location } + : {}), + }; +} + +function hasClaudeServerWebSearchTool(tools: unknown): boolean { + return Array.isArray(tools) && tools.some((tool) => isClaudeServerWebSearchTool(tool)); +} + +function shouldUseNativeResponsesWebSearch(credentials: unknown): boolean { + return ( + credentials !== null && + typeof credentials === "object" && + !Array.isArray(credentials) && + (credentials as JsonRecord)._targetFormat === FORMATS.OPENAI_RESPONSES + ); +} + // Convert Claude request to OpenAI format -export function claudeToOpenAIRequest(model, body, stream) { +export function claudeToOpenAIRequest(model, body, stream, credentials: unknown = null) { const result: { model: string; messages: JsonRecord[]; @@ -89,30 +136,31 @@ export function claudeToOpenAIRequest(model, body, stream) { // Fix missing tool responses - OpenAI requires every tool_call to have a response fixMissingToolResponses(result.messages); + const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials); + // Tools if (body.tools && Array.isArray(body.tools)) { const normalizedTools = body.tools .map((tool) => { - const name = typeof tool.name === "string" ? tool.name.trim() : ""; + if (useNativeResponsesWebSearch && isClaudeServerWebSearchTool(tool)) { + return convertClaudeServerWebSearchTool(tool); + } + + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return null; + const record = tool as JsonRecord; + const name = typeof record.name === "string" ? record.name.trim() : ""; if (!name) return null; // skip tools with empty/invalid name return { type: "function", function: { name, - description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276) - parameters: normalizeToolSchema(tool.input_schema), + description: typeof record.description === "string" ? record.description : "", // fix: never null (#276) + parameters: normalizeToolSchema(record.input_schema), }, }; }) - .filter( - ( - tool - ): tool is { - type: "function"; - function: { name: string; description: string; parameters: unknown }; - } => Boolean(tool) - ); + .filter((tool): tool is JsonRecord => Boolean(tool)); if (normalizedTools.length > 0) { result.tools = normalizedTools; @@ -121,7 +169,10 @@ export function claudeToOpenAIRequest(model, body, stream) { // Tool choice if (body.tool_choice) { - result.tool_choice = convertToolChoice(body.tool_choice); + result.tool_choice = convertToolChoice( + body.tool_choice, + useNativeResponsesWebSearch && hasClaudeServerWebSearchTool(body.tools) + ); } // Reasoning effort: map Claude-side thinking controls to OpenAI reasoning_effort. @@ -320,7 +371,7 @@ function convertClaudeMessage(msg) { } // Convert tool choice -function convertToolChoice(choice) { +function convertToolChoice(choice, hasServerWebSearch = false) { if (!choice) return "auto"; if (typeof choice === "string") return choice; @@ -330,6 +381,9 @@ function convertToolChoice(choice) { case TOOL_CHOICE_ANY: return "required"; case "tool": + if (hasServerWebSearch && choice.name === "web_search") { + return { type: "web_search" }; + } return { type: "function", function: { name: choice.name } }; default: return "auto"; diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 9c26c0084f..4129d3a808 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -18,6 +18,9 @@ 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/; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -85,6 +88,7 @@ export function openaiResponsesToOpenAIRequest( toolType !== "namespace" && !WEB_SEARCH_TOOL_TYPES.test(toolType) && !TOOL_SEARCH_TOOL_TYPES.test(toolType) && + !IMAGE_GENERATION_TOOL_TYPES.test(toolType) && !tool.function ) { throw unsupportedFeature( @@ -193,6 +197,13 @@ export function openaiResponsesToOpenAIRequest( if (!fnName) { continue; } + // #2893: Skip tool calls with an empty call_id — they can never be matched + // to their function_call_output, so the upstream rejects the orphaned tool + // result with "Messages with role 'tool' must be a response to a preceding + // message with 'tool_calls'". Dropping the unmatched pair avoids the 400. + if (!toString(item.call_id).trim()) { + continue; + } // Start or append assistant message with tool_calls if (!currentAssistantMsg) { @@ -267,8 +278,11 @@ export function openaiResponsesToOpenAIRequest( .filter((toolValue) => { const tool = toRecord(toolValue); const toolType = toString(tool.type); - // tool_search has no Chat Completions equivalent; drop it silently (issue #2766). - return !TOOL_SEARCH_TOOL_TYPES.test(toolType); + // tool_search (#2766) and image_generation (#2950) are Responses API built-ins + // with no Chat Completions equivalent; drop them silently. + return ( + !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType) + ); }) .map((toolValue) => { const tool = toRecord(toolValue); @@ -304,8 +318,11 @@ export function openaiResponsesToOpenAIRequest( } result.messages = messages.filter((m) => { const rec = toRecord(m); - if (rec.role === "tool" && rec.tool_call_id) { - return allToolCallIds.has(String(rec.tool_call_id)); + // #2893: drop ANY tool result whose tool_call_id has no matching tool_call — + // including empty/missing ids (the previous `&& rec.tool_call_id` guard let + // empty-id orphans slip through and triggered an upstream 400). + if (rec.role === "tool") { + return allToolCallIds.has(String(rec.tool_call_id ?? "")); } return true; }); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 3ad6aa7687..b7a5dd0666 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -108,7 +108,7 @@ type GeminiToolNameOptions = { stripNamespace?: boolean; functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; }; type OpenAIToolCallLike = { @@ -209,6 +209,29 @@ function buildInertHistoricalToolResponseText(name: string, response: unknown): ].join("\n"); } +function escapeHistoricalContextAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function escapeHistoricalContextContent(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function buildHistoricalToolResultContext(name: string, response: unknown): string { + const source = escapeHistoricalContextAttribute(name || "unknown"); + const rawResult = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + const result = escapeHistoricalContextContent(rawResult); + return [ + ``, + result, + "", + ].join("\n"); +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase( model: string, @@ -367,8 +390,10 @@ function openaiToGeminiBase( } let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); - const stringifySignaturelessToolCalls = - toolNameOptions.signaturelessToolCallMode === "text"; + const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode; + const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text"; + const contextualizeSignaturelessToolResponses = + signaturelessToolCallMode === "text" || signaturelessToolCallMode === "context"; for (const tc of toolCalls) { if (tc.type !== "function") continue; @@ -378,6 +403,9 @@ function openaiToGeminiBase( if (!fn) continue; const signatureForToolCall = resolvedSignatures.get(id); + if (!signatureForToolCall && contextualizeSignaturelessToolResponses) { + if (!toolCallIds.includes(id)) toolCallIds.push(id); + } if (!signatureForToolCall && stringifySignaturelessToolCalls) { const args = fn.arguments || "{}"; parts.push({ @@ -385,6 +413,9 @@ function openaiToGeminiBase( }); continue; } + if (!signatureForToolCall && signaturelessToolCallMode === "context") { + continue; + } const args = tryParseJSON(fn.arguments || "{}"); const embeddedThoughtSignature = shouldUseEmbeddedSignature @@ -405,7 +436,9 @@ function openaiToGeminiBase( }, }); - toolCallIds.push(id); + if (!contextualizeSignaturelessToolResponses || signatureForToolCall) { + toolCallIds.push(id); + } } if (parts.length > 0) { @@ -414,7 +447,7 @@ function openaiToGeminiBase( // Check if there are actual tool responses in the next messages const hasSignaturelessTextResponses = - stringifySignaturelessToolCalls && + contextualizeSignaturelessToolResponses && toolCalls.some((tc) => { const id = tc.id as string; return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id]; @@ -426,6 +459,7 @@ function openaiToGeminiBase( const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; + if (contextualizeSignaturelessToolResponses && !resolvedSignatures.has(fid)) continue; let name = tcID2Name[fid]; if (!name) { @@ -458,10 +492,13 @@ function openaiToGeminiBase( }); } - if (stringifySignaturelessToolCalls) { + if (contextualizeSignaturelessToolResponses) { // Signature-less historical tool responses are represented as text // so strict Gemini/Antigravity endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. + // In context mode the matching historical functionCall is omitted, + // avoiding pseudo tool-call records that Gemini Flash can repeat as + // the visible final answer. for (const tc of toolCalls) { const id = tc.id as string; if (tc.type !== "function" || !id) continue; @@ -470,7 +507,10 @@ function openaiToGeminiBase( const name = tcID2Name[id] || fn?.name || "unknown"; const resp = toolResponses[id]; toolParts.push({ - text: buildInertHistoricalToolResponseText(name, resp), + text: + signaturelessToolCallMode === "text" + ? buildInertHistoricalToolResponseText(name, resp) + : buildHistoricalToolResultContext(name, resp), }); } } @@ -551,7 +591,7 @@ export function openaiToGeminiRequest( stream: boolean, credentials: Record | null = null, options: { - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { // Thread the signature namespace so a thinking model's thoughtSignature (cached on the @@ -576,7 +616,7 @@ export function openaiToGeminiCLIRequest( options: { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { return openaiToGeminiBase(model, body, stream, { @@ -697,7 +737,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu : null; const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, { signatureNamespace, - signaturelessToolCallMode: isThinkingGemini ? "text" : "native", + signaturelessToolCallMode: isThinkingGemini ? "context" : "native", }); if (isClaude) { diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index a5b5ceafdf..0b50f80c0e 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -9,6 +9,34 @@ 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; +} + /** * Translate OpenAI chunk to Responses API events * @returns {Array} Array of events with { event, data } structure @@ -631,15 +659,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.toolCallIndex++; - let argsToEmit = item.arguments; - if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) { - // Fix #1674 & #1852: Strip empty string and array placeholders emitted by GPT-5.5 for optional fields - const cleaned = { ...argsToEmit }; - for (const [k, v] of Object.entries(cleaned)) { - if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k]; - } - argsToEmit = cleaned; - } + const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName); const argsStr = argsToEmit != null @@ -681,14 +701,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { // Only emit if arguments exist in the done event AND they weren't already streamed via deltas if (item.arguments != null && !buffered) { - let argsToEmit = item.arguments; - if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) { - const cleaned = { ...argsToEmit }; - for (const [k, v] of Object.entries(cleaned)) { - if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k]; - } - argsToEmit = cleaned; - } + const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName); const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); if (argsStr) { diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 2faaa6c5f1..86c4e31461 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -2,6 +2,28 @@ * AI SDK compatibility helpers (T26). */ +export type StreamDefaultMode = "legacy" | "json"; + +export interface ResolveStreamFlagOptions { + userAgent?: unknown; + streamDefaultMode?: unknown; +} + +function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): ResolveStreamFlagOptions { + if ( + optionsOrUserAgent && + typeof optionsOrUserAgent === "object" && + !Array.isArray(optionsOrUserAgent) + ) { + return optionsOrUserAgent as ResolveStreamFlagOptions; + } + return { userAgent: optionsOrUserAgent }; +} + +export function normalizeStreamDefaultMode(value: unknown): StreamDefaultMode { + return value === "json" ? "json" : "legacy"; +} + /** * Detects when a client explicitly prefers JSON (non-SSE) responses. */ @@ -29,20 +51,40 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean { export function resolveStreamFlag( bodyStream: unknown, acceptHeader: unknown, - sourceFormat?: string + sourceFormat?: string, + optionsOrUserAgent?: unknown ): boolean { // Explicit body value always wins if (bodyStream === true) return true; if (bodyStream === false) return false; + const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent); + const streamDefaultMode = normalizeStreamDefaultMode(options.streamDefaultMode); + + const acceptsEventStream = + typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader); + // Anthropic Messages API spec: stream defaults to false when body omits it. // Only honor an explicit text/event-stream Accept header as a streaming opt-in // for /v1/messages — otherwise default to non-stream so upstream JSON responses // are surfaced correctly instead of triggering stream_early_eof. if (sourceFormat === "claude") { - if (typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader)) { - return true; - } + if (acceptsEventStream) return true; + return false; + } + + // Nextcloud's OpenAI/LocalAI integration sends synchronous JSON requests and + // does not set `stream: false`. With a wildcard/empty Accept header, the legacy + // OmniRoute fallback would force SSE upstream and fail JSON-only providers as + // STREAM_EARLY_EOF before Nextcloud could receive a response. + if (isKnownJsonOnlyClient(options.userAgent) && !acceptsEventStream) { + return false; + } + + // Per-key compatibility mode for synchronous OpenAI-compatible clients that + // omit `stream`. This preserves legacy behavior by default while allowing an + // API key to use the OpenAI-compatible JSON default unless SSE is explicit. + if (streamDefaultMode === "json" && !acceptsEventStream) { return false; } @@ -51,6 +93,11 @@ export function resolveStreamFlag( return !clientWantsJsonResponse(acceptHeader); } +export function isKnownJsonOnlyClient(userAgent: unknown): boolean { + if (typeof userAgent !== "string") return false; + return /nextcloud\s+openai\/localai\s+integration/i.test(userAgent); +} + /** * Resolves explicit stream aliases used by non-standard clients. * Returns: diff --git a/open-sse/utils/estimateSize.ts b/open-sse/utils/estimateSize.ts new file mode 100644 index 0000000000..ac320f9aad --- /dev/null +++ b/open-sse/utils/estimateSize.ts @@ -0,0 +1,35 @@ +/** + * Fast object-tree size estimator — walks without JSON.stringify. + * Safe for circular references (uses WeakSet). + * Early-exits at 256KB to avoid wasting CPU on huge payloads. + */ +export function estimateSizeFast(value: unknown): number { + let bytes = 0; + const stack: unknown[] = [value]; + const seen = new WeakSet(); + while (stack.length > 0) { + const v = stack.pop(); + if (v === null || v === undefined) continue; + if (typeof v === "string") { + bytes += v.length; + if (bytes > 262144) return bytes; + } else if (typeof v === "number") bytes += 8; + else if (typeof v === "boolean") bytes += 4; + else if (typeof v === "object") { + if (seen.has(v as object)) continue; + seen.add(v as object); + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) stack.push(v[i]); + } else { + for (const key in v) { + if (Object.prototype.hasOwnProperty.call(v, key)) stack.push((v as Record)[key]); + } + } + } + } + return bytes; +} + +export function isSmallEnoughForSemanticCache(value: unknown): boolean { + return estimateSizeFast(value) <= 256 * 1024; +} diff --git a/package-lock.json b/package-lock.json index e7ba5d616a..bc576f81da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", "playwright": "1.60.0", - "proxifly": "^3.0.1", "react": "19.2.6", "react-dom": "19.2.6", "react-is": "^19.2.6", @@ -7727,6 +7726,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -7955,6 +7955,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8768,6 +8769,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concurrently": { @@ -11449,22 +11451,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/fs-jetpack": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-4.3.1.tgz", - "integrity": "sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==", - "license": "MIT", - "dependencies": { - "minimatch": "^3.0.2", - "rimraf": "^2.6.3" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -12635,17 +12621,6 @@ "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -13767,58 +13742,6 @@ "node": ">= 0.4" } }, - "node_modules/itwcw-package-analytics": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/itwcw-package-analytics/-/itwcw-package-analytics-1.0.8.tgz", - "integrity": "sha512-Uvu8uj2iDlAAKkugGSJ0WD1dSZW7wec8Vso8qvcfKfmtHz/QGqITdu8TPsle5FuNnIm9fr/H3vjM1YfzLj37jA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "fs-jetpack": "^4.3.1", - "uuid": "^9.0.1", - "wonderful-fetch": "^1.3.4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/itwcw-package-analytics/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/itwcw-package-analytics/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/itwcw-package-analytics/node_modules/wonderful-fetch": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/wonderful-fetch/-/wonderful-fetch-1.3.4.tgz", - "integrity": "sha512-vyo+dH0e7kqFBd6hHuU+3VO8+N6iQ87gLrg6cwdj63VaXyXBsZKiSO6CoWeKzK4eRvdlUMB4hnsfNXK+sYdGgQ==", - "license": "MIT", - "dependencies": { - "fs-jetpack": "^4.3.1", - "itwcw-package-analytics": "^1.0.6", - "json5": "^2.2.1", - "mime-types": "^2.1.35", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -14025,6 +13948,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -16063,6 +15987,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -16422,48 +16347,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-loader": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.1.0.tgz", @@ -17022,15 +16905,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -17487,19 +17361,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxifly": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/proxifly/-/proxifly-3.0.1.tgz", - "integrity": "sha512-tb3fnZLF/H4d2jQr6sO9NVRqTXmJgNZtb3cEuR7bTEqiGDx6weaFZqXSxaxZQYA+KsE4u2OW+nPchvZ3rK9FVg==", - "license": "MIT", - "dependencies": { - "itwcw-package-analytics": "^1.0.8", - "wonderful-fetch": "^2.0.4" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -18344,40 +18205,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -21275,50 +21102,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wonderful-fetch": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/wonderful-fetch/-/wonderful-fetch-2.0.5.tgz", - "integrity": "sha512-V8uDL2A+ZDxVA5s11QsOGBaMgr4i9v7kM4yacyThyrKvnj0VpWqMPxxZLZ3/UkH+5/jq9XyK0oMXyZpRT1E8LQ==", - "license": "MIT", - "dependencies": { - "fs-jetpack": "^5.1.0", - "itwcw-package-analytics": "^1.0.8", - "mime-types": "^3.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/wonderful-fetch/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/wonderful-fetch/node_modules/fs-jetpack": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-5.1.0.tgz", - "integrity": "sha512-Xn4fDhLydXkuzepZVsr02jakLlmoARPy+YWIclo4kh0GyNGUHnTqeH/w/qIsVn50dFxtp8otPL2t/HcPJBbxUA==", - "license": "MIT", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/wonderful-fetch/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 5ed04610bc..aa7363f81b 100644 --- a/package.json +++ b/package.json @@ -188,7 +188,6 @@ "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", "playwright": "1.60.0", - "proxifly": "^3.0.1", "react": "19.2.6", "react-dom": "19.2.6", "react-is": "^19.2.6", diff --git a/scripts/ad-hoc/diag-trae-auth.mjs b/scripts/ad-hoc/diag-trae-auth.mjs new file mode 100644 index 0000000000..fcacb0b3fd --- /dev/null +++ b/scripts/ad-hoc/diag-trae-auth.mjs @@ -0,0 +1,83 @@ +// Trae SOLO auth diagnostic — probes the live API with your token across several +// Authorization variants, so we can see which one (if any) the server accepts. +// +// Usage: +// node scripts/ad-hoc/diag-trae-auth.mjs +// TRAE_TOKEN=eyJ... node scripts/ad-hoc/diag-trae-auth.mjs +// +// Paste ONLY the token value (no "Cloud-IDE-JWT " prefix). The token is never +// printed in full; only its length + last 6 chars are shown for sanity. + +const token = (process.argv[2] || process.env.TRAE_TOKEN || "").trim(); +if (!token) { + console.error("No token. Pass it as arg or set TRAE_TOKEN."); + process.exit(1); +} +console.log(`token: len=${token.length} …${token.slice(-6)}`); + +const BASE = "https://core-normal.trae.ai/api/remote/v1"; +const MODELS = `${BASE}/models?functions=solo_agent_remote,solo_work_remote`; + +const commonHeaders = { + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "x-user-region": "US", + Referer: "https://solo.trae.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", +}; + +// Each variant = a set of auth-bearing headers to try. +const variants = [ + { + name: "Authorization: Cloud-IDE-JWT ", + headers: { Authorization: `Cloud-IDE-JWT ${token}` }, + }, + { name: "Authorization: Bearer ", headers: { Authorization: `Bearer ${token}` } }, + { name: "Authorization: (raw)", headers: { Authorization: token } }, + { name: "Cloud-IDE-JWT: (header)", headers: { "Cloud-IDE-JWT": token } }, + { name: "x-cloud-ide-jwt: ", headers: { "x-cloud-ide-jwt": token } }, + { name: "x-cloud-ide-token: ", headers: { "x-cloud-ide-token": token } }, +]; + +async function probe(label, url, method, headers) { + try { + const res = await fetch(url, { + method, + headers: { ...commonHeaders, ...headers }, + body: + method === "POST" + ? JSON.stringify({ mode: "code", env: "remote", origin: "web" }) + : undefined, + }); + const text = await res.text(); + const snippet = text.replace(/\s+/g, " ").slice(0, 160); + const ok = res.ok && !/"code"\s*:\s*1001/.test(text) && !/not able to authenticate/i.test(text); + console.log(`${ok ? "✅" : "❌"} [${res.status}] ${label}\n ${snippet}`); + return ok; + } catch (e) { + console.log(`💥 ${label} → ${e?.message || e}`); + return false; + } +} + +console.log(`\n=== GET ${MODELS} ===`); +let anyOk = false; +for (const v of variants) { + const ok = await probe(v.name, MODELS, "GET", v.headers); + anyOk = anyOk || ok; +} + +console.log(`\n=== POST ${BASE}/chat_sessions (only the first auth variant, sanity) ===`); +await probe(variants[0].name, `${BASE}/chat_sessions`, "POST", variants[0].headers); + +console.log( + anyOk + ? "\n→ At least one variant authenticated. Tell me which ✅ line, I'll set the executor to it." + : "\n→ No header-only variant worked. The web client likely authenticates via a COOKIE, not a\n" + + " bearer header. Next step: in DevTools → Network, right-click a request to\n" + + " core-normal.trae.ai → Copy → Copy as cURL, and paste it here (redact the token). That\n" + + " shows the exact auth header/cookie the server actually accepts." +); diff --git a/scripts/ad-hoc/smoke-trae.mjs b/scripts/ad-hoc/smoke-trae.mjs new file mode 100644 index 0000000000..fc6f68d6b8 --- /dev/null +++ b/scripts/ad-hoc/smoke-trae.mjs @@ -0,0 +1,61 @@ +// End-to-end smoke test: drives TraeExecutor against the real Trae API with your +// JWT from trae_solo.env (kept outside the repo), printing content + usage. +// Run: node --import tsx/esm scripts/ad-hoc/smoke-trae.mjs + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.resolve(__dirname, "../../../trae_solo.env"); +if (!fs.existsSync(envPath)) { + console.error(`Не найден ${envPath}. Положи туда TRAE_TOKEN= и TRAE_WEB_ID= и т.д.`); + process.exit(1); +} +const cfg = Object.fromEntries( + fs + .readFileSync(envPath, "utf8") + .split("\n") + .filter((l) => l && !l.startsWith("#") && l.includes("=")) + .map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i), l.slice(i + 1)]; + }) +); + +const { TraeExecutor } = await import("../../open-sse/executors/trae.ts"); +const ex = new TraeExecutor(); + +const credentials = { + accessToken: cfg.TRAE_TOKEN, + providerSpecificData: { + webId: cfg.TRAE_WEB_ID || "", + bizUserId: cfg.TRAE_BIZ_USER_ID || "", + userUniqueId: cfg.TRAE_USER_UNIQUE_ID || "", + scope: cfg.TRAE_SCOPE || "marscode-us", + tenant: cfg.TRAE_TENANT || "marscode", + region: cfg.TRAE_REGION || "US-East", + aiRegion: cfg.TRAE_AIREGION || cfg.TRAE_REGION || "US-East", + appLanguage: cfg.TRAE_APP_LANGUAGE || "en", + appVersion: cfg.TRAE_APP_VERSION || "1.0.0.1229", + }, +}; + +const model = process.argv[2] || "auto"; +const prompt = process.argv.slice(3).join(" ") || "Ответь одним словом: столица Франции?"; + +console.log(`[smoke] model=${model} prompt=${JSON.stringify(prompt)}`); +const { response } = await ex.execute({ + model, + body: { messages: [{ role: "user", content: prompt }] }, + stream: false, + credentials, +}); +const text = await response.text(); +if (response.status !== 200) { + console.error(`[smoke] HTTP ${response.status}\n${text}`); + process.exit(1); +} +const json = JSON.parse(text); +console.log("content:", JSON.stringify(json.choices?.[0]?.message?.content)); +console.log("usage: ", json.usage); diff --git a/scripts/build/runtime-env.mjs b/scripts/build/runtime-env.mjs index b3e761bf15..76b8df8b92 100644 --- a/scripts/build/runtime-env.mjs +++ b/scripts/build/runtime-env.mjs @@ -5,6 +5,20 @@ export function parsePort(value, fallback) { return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; } +/** + * Resolve the V8 heap ceiling (MB) for the server process from + * `OMNIROUTE_MEMORY_MB`, mirroring `omniroute serve`. Clamped to [64, 16384]; + * invalid/unset → fallback (512). The standalone launcher uses this so + * OMNIROUTE_MEMORY_MB can override the Docker image's NODE_OPTIONS fallback + * without clobbering any other runtime flags (#2939). + * @param {string | number | undefined | null} value + * @param {number} [fallback] + */ +export function resolveMaxOldSpaceMb(value, fallback = 512) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback; +} + /** * @param {NodeJS.ProcessEnv | Record} [fromEnv] * Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn. diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh index 13eedf45b0..88e2dba4bc 100755 --- a/scripts/check-permissions.sh +++ b/scripts/check-permissions.sh @@ -1,6 +1,13 @@ #!/bin/sh set -e +# ── Memory limit override ────────────────────────────────────────────── +# If OMNIROUTE_MEMORY_MB is set, build NODE_OPTIONS dynamically so the +# user can tune heap size via environment without editing the Dockerfile. +if [ -n "$OMNIROUTE_MEMORY_MB" ]; then + export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}" +fi + if [ -d "/app/data" ] && [ ! -w "/app/data" ]; then echo "WARNING: /app/data is not writable by the current user (UID $(id -u))." echo "Run this on the Docker host to fix:" diff --git a/scripts/dev/run-standalone.mjs b/scripts/dev/run-standalone.mjs index b1bef083b4..531322da77 100644 --- a/scripts/dev/run-standalone.mjs +++ b/scripts/dev/run-standalone.mjs @@ -4,12 +4,22 @@ import { existsSync } from "node:fs"; import { resolveRuntimePorts, withRuntimePortEnv, + resolveMaxOldSpaceMb, spawnWithForwardedSignals, } from "../build/runtime-env.mjs"; import { bootstrapEnv } from "../build/bootstrap-env.mjs"; const env = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(env); +const childEnv = withRuntimePortEnv(env, runtimePorts); + +// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob +// `omniroute serve` uses, so Docker users can control the server heap under +// load / large SQLite DBs. A trailing --max-old-space-size wins, so this +// overrides the image fallback without clobbering any other NODE_OPTIONS flags. +const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB); +childEnv.NODE_OPTIONS = + `${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim(); // Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone // server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs) @@ -20,5 +30,5 @@ const entry = existsSync("server-ws.mjs") ? "server-ws.mjs" : "server.js"; spawnWithForwardedSignals("node", [entry], { stdio: "inherit", - env: withRuntimePortEnv(env, runtimePorts), + env: childEnv, }); diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 739cd9a1ea..0c62159272 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -11,6 +11,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; +import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); @@ -117,6 +118,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [selectedProvider, setSelectedProvider] = useState(null); const [providerMetrics, setProviderMetrics] = useState>({}); const [activeRequests, setActiveRequests] = useState([]); + const [providerNodes, setProviderNodes] = useState< + Array<{ id?: string; prefix?: string; name?: string }> + >([]); const [versionInfo, setVersionInfo] = useState(null); const [updating, setUpdating] = useState(false); @@ -268,6 +272,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { fetchData(); }, [fetchData]); + // Fetch provider nodes for display labels (compat providers) + useEffect(() => { + fetch("/api/provider-nodes") + .then((r) => (r.ok ? r.json() : { nodes: [] })) + .then((d) => setProviderNodes(d.nodes || [])) + .catch(() => {}); + }, []); + useEffect(() => { let cancelled = false; let timeoutId: ReturnType | null = null; @@ -464,10 +476,16 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const canonicalProviderId = normalizeProviderId(rawProviderId); if (!canonicalProviderId || byProvider.has(canonicalProviderId)) return; + const resolvedName = + getProviderDisplayLabel(rawProviderId, providerNodes) || + name || + providerConfig[canonicalProviderId]?.name || + rawProviderId; + byProvider.set(canonicalProviderId, { id: canonicalProviderId, provider: canonicalProviderId, - name: name || providerConfig[canonicalProviderId]?.name || rawProviderId, + name: resolvedName, }); }; @@ -478,7 +496,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { activeRequests.forEach((request) => addProvider(request.provider)); return Array.from(byProvider.values()); - }, [providerStats, providerMetrics, activeRequests]); + }, [providerStats, providerMetrics, activeRequests, providerNodes]); const topologyActiveRequests = useMemo( () => diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index f9480e52c9..8c7850e6d0 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, memo } from "react"; +import { useState, useEffect, useMemo, useCallback, memo, useRef, useId } from "react"; import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; @@ -80,6 +80,8 @@ interface AccessSchedule { tz: string; } +type StreamDefaultMode = "legacy" | "json"; + interface ApiKey { id: string; name: string; @@ -98,6 +100,7 @@ interface ApiKey { rateLimits?: Array<{ limit: number; window: number }> | null; scopes?: string[]; allowedEndpoints?: string[]; + streamDefaultMode?: StreamDefaultMode; createdAt: string; } @@ -130,6 +133,8 @@ type ProviderGroup = [provider: string, models: Model[]]; export default function ApiManagerPageClient() { const t = useTranslations("apiManager"); const tc = useTranslations("common"); + const newKeyNameInputId = useId(); + const createKeyFormRef = useRef(null); const [keys, setKeys] = useState([]); const [allModels, setAllModels] = useState([]); const [allCombos, setAllCombos] = useState([]); @@ -151,6 +156,7 @@ export default function ApiManagerPageClient() { const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); + const createKeyNameFieldRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [activeOnly, setActiveOnly] = useState(false); @@ -159,6 +165,17 @@ export default function ApiManagerPageClient() { const { copied, copy } = useCopyToClipboard(); + const scrollCreateKeyFormToTop = useCallback(() => { + const scrollContainer = createKeyFormRef.current?.parentElement; + if (scrollContainer instanceof HTMLElement) { + scrollContainer.scrollTop = 0; + } + + const input = document.getElementById(newKeyNameInputId); + input?.scrollIntoView({ block: "nearest", inline: "nearest" }); + input?.focus({ preventScroll: true }); + }, [newKeyNameInputId]); + useEffect(() => { fetchData(); fetchModels(); @@ -166,6 +183,13 @@ export default function ApiManagerPageClient() { fetchConnections(); }, []); + useEffect(() => { + if (!showAddModal || !nameError) return; + requestAnimationFrame(() => { + createKeyNameFieldRef.current?.scrollIntoView({ block: "center", behavior: "instant" }); + }); + }, [nameError, showAddModal]); + useEffect(() => { setActiveOnly(readActiveOnlyPreference()); }, []); @@ -174,6 +198,16 @@ export default function ApiManagerPageClient() { writeActiveOnlyPreference(activeOnly); }, [activeOnly]); + useEffect(() => { + if (!showAddModal || !nameError) return; + + const timeout = window.setTimeout(() => { + scrollCreateKeyFormToTop(); + }, 0); + + return () => window.clearTimeout(timeout); + }, [showAddModal, nameError, scrollCreateKeyFormToTop]); + const fetchModels = async () => { try { const res = await fetch("/v1/models"); @@ -346,6 +380,7 @@ export default function ApiManagerPageClient() { // Validate raw input first, then sanitize const validation = validateKeyName(newKeyName, t); if (!validation.valid) { + scrollCreateKeyFormToTop(); setNameError(validation.error || t("invalidKeyName")); return; } @@ -480,7 +515,8 @@ export default function ApiManagerPageClient() { accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, scopes: string[], - allowedEndpoints: string[] + allowedEndpoints: string[], + streamDefaultMode: StreamDefaultMode ) => { if (!editingKey || !editingKey.id) return; @@ -541,6 +577,7 @@ export default function ApiManagerPageClient() { rateLimits, scopes, allowedEndpoints, + streamDefaultMode, }), }); @@ -791,6 +828,7 @@ export default function ApiManagerPageClient() { : 0; const hasThrottle = throttleDelayMs > 0; const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage"); + const hasJsonStreamDefault = key.streamDefaultMode === "json"; const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; const activeSessions = sessionCounts[key.id] || 0; @@ -885,6 +923,12 @@ export default function ApiManagerPageClient() { Auto-Resolve )} + {hasJsonStreamDefault && ( + + data_object + {t("streamDefaultBadge")} + + )} {hasSessionLimit && ( group @@ -1011,6 +1055,7 @@ export default function ApiManagerPageClient() { { setShowAddModal(false); setNewKeyName(""); @@ -1021,12 +1066,13 @@ export default function ApiManagerPageClient() { setCreateError(null); }} > -
-
+
+
{ setNewKeyName(e.target.value); @@ -1238,7 +1284,8 @@ const PermissionsModal = memo(function PermissionsModal({ accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, scopes: string[], - allowedEndpoints: string[] + allowedEndpoints: string[], + streamDefaultMode: StreamDefaultMode ) => void; }) { const t = useTranslations("apiManager"); @@ -1289,6 +1336,9 @@ const PermissionsModal = memo(function PermissionsModal({ const [rateLimits, setRateLimits] = useState>( Array.isArray(apiKey?.rateLimits) ? apiKey.rateLimits : [] ); + const [streamDefaultMode, setStreamDefaultMode] = useState( + apiKey?.streamDefaultMode === "json" ? "json" : "legacy" + ); const [nameError, setNameError] = useState(null); const [saveError, setSaveError] = useState(null); const [selectedConnections, setSelectedConnections] = useState(initialConnections); @@ -1453,7 +1503,8 @@ const PermissionsModal = memo(function PermissionsModal({ selfUsageEnabled, selfAccountQuotaEnabled, }), - allowAllEndpoints ? [] : selectedEndpoints + allowAllEndpoints ? [] : selectedEndpoints, + streamDefaultMode ); }, [ onSave, @@ -1482,6 +1533,7 @@ const PermissionsModal = memo(function PermissionsModal({ rateLimits, allowAllEndpoints, selectedEndpoints, + streamDefaultMode, apiKey?.scopes, t, ]); @@ -1863,6 +1915,40 @@ const PermissionsModal = memo(function PermissionsModal({
+ {/* Stream Default Compatibility */} +
+
+

{t("streamDefaultMode")}

+

{t("streamDefaultModeDesc")}

+
+
+ + +
+
+ {/* Ban Toggle (SECURITY) */}
@@ -1909,11 +1995,10 @@ const PermissionsModal = memo(function PermissionsModal({

{t("managementAccess")}

-

- Allow this API key to manage OmniRoute configuration. -

+

{t("managementAccessDesc")}

{t("ownUsageVisibilityDesc")}

+ + {expanded && ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + {!connected ? ( +
+ +
+ setToken(e.target.value)} + placeholder="ntn_... or secret_..." + disabled={busy} + className="font-mono text-sm flex-1" + /> + +
+

+ Create an Internal Integration at{" "} + + https://www.notion.so/profile/integrations + +

+
+ ) : ( +
+ + Token configured. Notion tools are available via MCP. + + +
+ )} +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index d02dbd7063..6ecd6f130e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -27,6 +27,7 @@ import { OAuthModal, KiroOAuthWrapper, CursorAuthModal, + TraeAuthModal, Toggle, Select, ProxyConfigModal, @@ -788,6 +789,7 @@ interface AddApiKeyModalProps { isOpen: boolean; provider?: string; providerName?: string; + initialBaseUrl?: string; isCompatible?: boolean; isAnthropic?: boolean; isCcCompatible?: boolean; @@ -821,6 +823,11 @@ type CommandCodeAuthFlowState = { message?: string; }; +const SILICONFLOW_ENDPOINTS = [ + { id: "siliconflow", label: "Global", baseUrl: "https://api.siliconflow.com/v1" }, + { id: "siliconflow-cn", label: "China", baseUrl: "https://api.siliconflow.cn/v1" }, +] as const; + interface EditConnectionModalConnection { id?: string; name?: string; @@ -1361,6 +1368,8 @@ export default function ProviderDetailPage() { const [showOAuthModal, _setShowOAuthModal] = useState(false); const [reauthConnection, setReauthConnection] = useState(null); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false); + const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState(); const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false); const [commandCodeAuthState, setCommandCodeAuthState] = useState({ phase: "idle", @@ -1619,6 +1628,19 @@ export default function ProviderDetailPage() { } }, [providerId, isSearchProvider]); + const fetchProxyConfig = useCallback(async () => { + try { + const res = await fetch("/api/settings/proxy", { cache: "no-store" }); + if (res.ok) { + setProxyConfig(await res.json()); + } else { + setProxyConfig(null); + } + } catch { + // Proxy indicators are best-effort. + } + }, []); + const fetchConnections = useCallback(async () => { try { const [connectionsRes, nodesRes] = await Promise.all([ @@ -1680,11 +1702,8 @@ export default function ProviderDetailPage() { fetchConnections(); fetchAliases(); // Load proxy config for visual indicators (provider-level button) - fetch("/api/settings/proxy") - .then((r) => (r.ok ? r.json() : null)) - .then((c) => setProxyConfig(c)) - .catch(() => {}); - }, [fetchConnections, fetchAliases]); + void fetchProxyConfig(); + }, [fetchConnections, fetchAliases, fetchProxyConfig]); const handleZedImport = useCallback(async () => { if (importingZed) return; @@ -1949,13 +1968,21 @@ export default function ProviderDetailPage() { setShowOAuthModal(false); }, [fetchConnections]); + const openApiKeyAddFlow = useCallback(() => { + if (providerId === "siliconflow") { + setShowSiliconFlowEndpointModal(true); + return; + } + setShowAddApiKeyModal(true); + }, [providerId]); + const openPrimaryAddFlow = useCallback(() => { if (isOAuth) { setShowOAuthModal(true); return; } - setShowAddApiKeyModal(true); - }, [isOAuth]); + openApiKeyAddFlow(); + }, [isOAuth, openApiKeyAddFlow]); const gateConnectionFlow = useCallback( (callback: () => void) => { @@ -1998,6 +2025,7 @@ export default function ProviderDetailPage() { const handleCloseAddApiKeyModal = useCallback(() => { clearCommandCodeAuthTimer(); + setSiliconFlowInitialBaseUrl(undefined); commandCodeAuthWindowRef.current?.close?.(); commandCodeAuthWindowRef.current = null; setCommandCodeAuthState({ @@ -2245,6 +2273,7 @@ export default function ProviderDetailPage() { const newConnection = connectionData?.connection; await fetchConnections(); setShowAddApiKeyModal(false); + setSiliconFlowInitialBaseUrl(undefined); // For Gemini: show progress dialog and sync models from endpoint if (providerId === "gemini" && newConnection?.id) { @@ -3905,11 +3934,7 @@ export default function ProviderDetailPage() {

- @@ -4133,11 +4158,7 @@ export default function ProviderDetailPage() { ) : ( connections.length === 0 && ( - ) @@ -4172,7 +4193,7 @@ export default function ProviderDetailPage() { @@ -4688,6 +4709,15 @@ export default function ProviderDetailPage() { setShowOAuthModal(false); }} /> + ) : providerId === "trae" ? ( + { + setShowOAuthModal(false); + }} + /> ) : ( ))} + {providerId === "siliconflow" && ( + { + setSiliconFlowInitialBaseUrl(baseUrl); + setShowSiliconFlowEndpointModal(false); + setShowAddApiKeyModal(true); + }} + onClose={() => { + setShowSiliconFlowEndpointModal(false); + setSiliconFlowInitialBaseUrl(undefined); + }} + /> + )} {!isUpstreamProxyProvider && ( void loadConnProxies(connections)} + onSaved={() => { + void fetchProxyConfig(); + void loadConnProxies(connections); + }} /> )} {/* Import Progress Modal */} @@ -7295,6 +7343,7 @@ const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "azure-ai", "bailian-coding-plan", "xiaomi-mimo", + "siliconflow", "heroku", "databricks", "snowflake", @@ -7307,6 +7356,7 @@ const DEFAULT_PROVIDER_BASE_URLS: Record = { "azure-ai": "https://example-resource.services.ai.azure.com/openai/v1", "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", "xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1", + siliconflow: "https://api.siliconflow.com/v1", "searxng-search": "http://localhost:8888/search", petals: "https://chat.petals.dev/api/v1/generate", }; @@ -7372,6 +7422,8 @@ function getProviderBaseUrlPlaceholder(providerId?: string | null) { case "bailian-coding-plan": case "xiaomi-mimo": return getProviderBaseUrlDefault(providerId); + case "siliconflow": + return "https://api.siliconflow.cn/v1"; case "heroku": return "https://us.inference.heroku.com"; case "databricks": @@ -7475,10 +7527,60 @@ function extractCommandCodeCredentialInput(value: string): string { return trimmed; } +function SiliconFlowEndpointModal({ + isOpen, + onSelect, + onClose, +}: { + isOpen: boolean; + onSelect: (baseUrl: string) => void; + onClose: () => void; +}) { + const t = useTranslations("providers"); + + return ( + +
+

+ {providerText(t, "chooseSiliconFlowEndpoint", "Choose your SiliconFlow endpoint:")} +

+ {SILICONFLOW_ENDPOINTS.map((endpoint) => ( + + ))} +
+
+ ); +} + function AddApiKeyModal({ isOpen, provider, providerName, + initialBaseUrl, isCompatible, isAnthropic, isCcCompatible, @@ -7524,7 +7626,7 @@ function AddApiKeyModal({ name: "", apiKey: "", priority: 1, - baseUrl: defaultBaseUrl, + baseUrl: initialBaseUrl || defaultBaseUrl, cx: "", region: showsRegion ? defaultRegion : "", apiRegion: "international", @@ -7543,6 +7645,17 @@ function AddApiKeyModal({ const [saveError, setSaveError] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [copiedCommandCodeField, setCopiedCommandCodeField] = useState(null); + const wasOpenRef = useRef(false); + + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = isOpen; + if (!isOpen || wasOpen) return; + setFormData((current) => ({ + ...current, + baseUrl: initialBaseUrl || defaultBaseUrl, + })); + }, [defaultBaseUrl, initialBaseUrl, isOpen]); const bulkSupported = supportsBulkApiKey(provider); const [mode, setMode] = useState<"single" | "bulk">("single"); @@ -7757,6 +7870,16 @@ function AddApiKeyModal({ setSaveError(null); try { + let providerSpecificData: Record | undefined; + if (usesBaseUrl) { + const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); + if (checked.error) { + setSaveError(checked.error); + return; + } + providerSpecificData = { baseUrl: checked.value }; + } + const res = await fetch("/api/providers/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -7764,6 +7887,7 @@ function AddApiKeyModal({ provider, entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })), priority: formData.priority || 1, + providerSpecificData, validateKeys: bulkValidateKeys, }), }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index d755a22ca7..d479c1235f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -141,6 +141,12 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { placeholder: "session=... or full Cookie header from doubao.com", acceptsFullCookieHeader: true, }, + "qwen-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", + acceptsFullCookieHeader: false, + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 36ff779742..64a3727e59 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -78,6 +78,8 @@ export async function PATCH(request, { params }) { accessSchedule, rateLimits, scopes, + allowedEndpoints, + streamDefaultMode, } = validation.data; const payload: Parameters[1] = {}; @@ -95,6 +97,8 @@ export async function PATCH(request, { params }) { if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule; if (rateLimits !== undefined) payload.rateLimits = rateLimits; if (scopes !== undefined) payload.scopes = scopes; + if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints; + if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode; const updated = await updateApiKeyPermissions(id, payload); if (!updated) { @@ -120,6 +124,8 @@ export async function PATCH(request, { params }) { ...(accessSchedule !== undefined && { accessSchedule }), ...(rateLimits !== undefined && { rateLimits }), ...(scopes !== undefined && { scopes }), + ...(allowedEndpoints !== undefined && { allowedEndpoints }), + ...(streamDefaultMode !== undefined && { streamDefaultMode }), }); } catch (error) { log.error("keys", "Error updating key permissions", error); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 2a1747a4c1..0c69f50be3 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -83,6 +83,7 @@ export async function POST(request) { id: apiKey.id, machineId: apiKey.machineId, noLog: noLog === true, + streamDefaultMode: "legacy", }, { status: 201 } ); diff --git a/src/app/api/oauth/trae/import/route.ts b/src/app/api/oauth/trae/import/route.ts new file mode 100644 index 0000000000..9f3fba5bac --- /dev/null +++ b/src/app/api/oauth/trae/import/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { traeImportSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; + +/** + * POST /api/oauth/trae/import + * + * Persist a pasted Trae SOLO Cloud-IDE-JWT (plus optional identity fields). + * No public OAuth round-trip exists for solo.trae.ai — the user signs in via + * the web client, copies the JWT (Authorization: Cloud-IDE-JWT ), and + * pastes it here. JWT lifetime is ~14 days; re-import on expiry. + * + * Request body (JSON): + * accessToken — required, the Cloud-IDE-JWT + * webId — optional, common_params.web_id + * bizUserId — optional, common_params.biz_user_id + * userUniqueId — optional, common_params.user_unique_id + * scope — optional, default "marscode-us" + * tenant — optional, default "marscode" + * region — optional, default "US-East" + */ +async function requireOAuthImportAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function POST(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(traeImportSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { accessToken, webId, bizUserId, userUniqueId, scope, tenant, region } = validation.data; + + const connection: any = await createProviderConnection({ + provider: "trae", + authType: "oauth", + accessToken, + refreshToken: null, + // Trae JWTs we've observed expire ~14 days after issuance; expose that + // hint to the dashboard so the user gets a heads-up before they break. + expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), + providerSpecificData: { + webId: webId || "", + bizUserId: bizUserId || "", + userUniqueId: userUniqueId || "", + scope: scope || "marscode-us", + tenant: tenant || "marscode", + region: region || "US-East", + aiRegion: region || "US-East", + appLanguage: "en", + appVersion: "1.0.0.1229", + userRegion: "US", + userIdentity: "Free", + authMethod: "imported", + }, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { id: connection.id, provider: connection.provider }, + }); + } catch (error: any) { + console.error("Trae import token error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +/** + * GET /api/oauth/trae/import + * Returns field metadata so a generic dashboard UI can render the paste form. + */ +export async function GET(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + return NextResponse.json({ + provider: "trae", + method: "import_token", + instructions: + "Sign in to solo.trae.ai, then copy the JWT sent in the 'Authorization: Cloud-IDE-JWT ' header (DevTools → Network → any POST to core-normal.trae.ai). Paste it as accessToken. Optionally provide webId/bizUserId/userUniqueId for full identity propagation.", + requiredFields: [ + { + name: "accessToken", + label: "Access Token (Cloud-IDE-JWT)", + description: "JWT from Authorization header on solo.trae.ai requests.", + type: "textarea", + required: true, + }, + { name: "webId", label: "Web ID", description: "common_params.web_id", type: "text" }, + { + name: "bizUserId", + label: "Biz User ID", + description: "common_params.biz_user_id", + type: "text", + }, + { + name: "userUniqueId", + label: "User Unique ID", + description: "common_params.user_unique_id (often equals bizUserId)", + type: "text", + }, + { name: "scope", label: "Scope", description: "default: marscode-us", type: "text" }, + { name: "tenant", label: "Tenant", description: "default: marscode", type: "text" }, + { name: "region", label: "Region", description: "default: US-East", type: "text" }, + ], + }); +} diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 4956aaf962..46997bc24a 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -96,7 +96,8 @@ export async function POST(request) { if (isValidationFailure(validation)) { return Response.json({ error: validation.error }, { status: 400 }); } - const { provider, modelId, modelName, source, apiFormat, supportedEndpoints } = validation.data; + const { provider, modelId, modelName, source, apiFormat, supportedEndpoints, targetFormat } = + validation.data; const model = await addCustomModel( provider, @@ -104,7 +105,8 @@ export async function POST(request) { modelName, source || "manual", apiFormat, - supportedEndpoints + supportedEndpoints, + targetFormat ); return Response.json({ model }); } catch (error) { @@ -150,6 +152,7 @@ export async function PUT(request) { modelName, apiFormat, supportedEndpoints, + targetFormat, normalizeToolCallId, preserveOpenAIDeveloperRole, upstreamHeaders, @@ -161,6 +164,7 @@ export async function PUT(request) { if ("modelName" in raw) updates.modelName = modelName; if ("apiFormat" in raw) updates.apiFormat = apiFormat; if ("supportedEndpoints" in raw) updates.supportedEndpoints = supportedEndpoints; + if ("targetFormat" in raw) updates.targetFormat = targetFormat; if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId; if ("preserveOpenAIDeveloperRole" in raw) updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index d766925886..99a49736cc 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -13,6 +13,7 @@ import { validateProviderApiKey } from "@/lib/providers/validation"; import { getCliRuntimeStatus } from "@/shared/services/cliRuntime"; // Use the shared open-sse token refresh with built-in dedup/race-condition cache import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.ts"; +import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { saveCallLog } from "@/lib/usageDb"; import { logProxyEvent } from "@/lib/proxyLogger"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; @@ -420,9 +421,14 @@ async function testOAuthConnection(connection: any) { let refreshed = false; let newTokens = null; - // Auto-refresh if token is expired and provider supports refresh + // Auto-refresh if token is expired and provider supports refresh. + // Front 2: NEVER burn a rotating provider's single-use refresh_token from a + // connection test. Under a shared Auth0 client (Codex/OpenAI) a test-time + // refresh can cascade-invalidate sibling accounts' refresh_token families + // (openai/codex#9648). Leave rotation to the reactive, mutex-guarded 401 path. const tokenExpired = isTokenExpired(connection); - if (config.refreshable && tokenExpired && connection.refreshToken) { + const isRotatingProvider = rotationGroupFor(connection.provider) !== null; + if (config.refreshable && tokenExpired && connection.refreshToken && !isRotatingProvider) { const tokens = await refreshOAuthToken(connection); if (tokens) { accessToken = tokens.accessToken; @@ -454,6 +460,19 @@ async function testOAuthConnection(connection: any) { } // Check if token is expired (no refresh available) if (tokenExpired) { + // Front 2: for rotating providers we intentionally did NOT refresh above. + // An expired access_token here is recoverable on next real use via the + // reactive 401 path, so don't report the account as broken (which would + // tempt the operator to re-test and never resolve). Keep it active. + if (isRotatingProvider && connection.refreshToken) { + return { + valid: true, + error: null, + refreshed: false, + newTokens: null, + diagnosis: makeDiagnosis("ok", "oauth", null, null), + }; + } const error = "Token expired"; return { valid: false, diff --git a/src/app/api/settings/notion/route.ts b/src/app/api/settings/notion/route.ts new file mode 100644 index 0000000000..dc92e14189 --- /dev/null +++ b/src/app/api/settings/notion/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + getNotionConfig, + setNotionToken, + clearNotionToken, +} from "@/lib/db/notion"; +import { createNotionClient } from "@/lib/notion/api"; + +const setTokenSchema = z.object({ + token: z.string().min(1).max(500), +}).strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const config = getNotionConfig(); + return NextResponse.json({ + connected: config.connected, + hasToken: config.token !== null, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = setTokenSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { error: "Missing or invalid token", details: parsed.error.issues }, + { status: 400 } + ); + } + + try { + setNotionToken(parsed.data.token); + + const client = createNotionClient(parsed.data.token); + const result = await client.searchPagesAndDatabases("test", undefined, 1); + if (result && typeof result === "object" && "object" in result && (result as Record).object === "error") { + clearNotionToken(); + return NextResponse.json( + { error: "Token validation failed: invalid token", connected: false }, + { status: 400 } + ); + } + + return NextResponse.json({ + connected: true, + message: "Notion integration token saved and validated", + }); + } catch (error) { + clearNotionToken(); + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg, connected: false }, { status: 400 }); + } +} + +export async function DELETE(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + clearNotionToken(); + return NextResponse.json({ + connected: false, + message: "Notion integration disconnected", + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index 59b64aec69..78638fa407 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -160,6 +160,30 @@ export async function GET(request: Request) { return Response.json({ level, id, proxy }); } + if (level === "provider" && id) { + const assignments = await getProxyAssignments({ scope: "provider" }); + const assignment = assignments.find((entry) => entry.scopeId === id); + if (assignment?.proxyId) { + const proxyData = await getProxyById(assignment.proxyId, { includeSecrets: true }); + if (proxyData) { + return Response.json({ + level, + id, + proxy: { + type: proxyData.type, + host: proxyData.host, + port: proxyData.port, + username: proxyData.username, + password: proxyData.password, + }, + }); + } + } + + const proxy = await getProxyForLevel(level, id); + return Response.json({ level, id, proxy }); + } + if (level) { const proxy = await getProxyForLevel(level, id); return Response.json({ level, id, proxy }); @@ -167,6 +191,31 @@ export async function GET(request: Request) { // Get full config const config = await getProxyConfig(); + const providerAssignments = await getProxyAssignments({ scope: "provider" }); + if (providerAssignments.length > 0) { + config.providers = { ...(config.providers || {}) }; + const providerProxyResults = await Promise.all( + providerAssignments.map(async (assignment) => { + if (!assignment.scopeId || !assignment.proxyId) { + return null; + } + const proxyData = await getProxyById(assignment.proxyId, { includeSecrets: true }); + if (!proxyData) return null; + return { scopeId: assignment.scopeId, proxyData }; + }) + ); + + for (const result of providerProxyResults) { + if (!result) continue; + config.providers[result.scopeId] = { + type: result.proxyData.type, + host: result.proxyData.host, + port: result.proxyData.port, + username: result.proxyData.username, + password: result.proxyData.password, + }; + } + } return Response.json(config); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to load proxy config"); diff --git a/src/app/authorize/parseCallback.ts b/src/app/authorize/parseCallback.ts new file mode 100644 index 0000000000..ff86d767ca --- /dev/null +++ b/src/app/authorize/parseCallback.ts @@ -0,0 +1,97 @@ +/** + * Pure parser for the Trae SOLO /authorize callback query string. Extracted + * from route.ts so it can be unit-tested without touching the DB layer. + * + * Returns the credential bundle that the route hands to createProviderConnection, + * or a structured error if the payload is missing/malformed. + */ +export type ParsedTraeCallback = { + ok: true; + record: { + provider: "trae"; + authType: "oauth"; + accessToken: string; + refreshToken: string | null; + expiresAt: string | null; + email: string | null; + providerSpecificData: { + userId: string; + tenantId: string; + bizUserId: string; + userUniqueId: string; + webId: string; + scope: "marscode-us"; + tenant: "marscode"; + region: string; + aiRegion: string; + host: string; + screenName: string | null; + clientId: string; + refreshExpireAt: number | null; + authMethod: "oauth_callback"; + }; + testStatus: "active"; + }; +}; + +export type ParseError = { ok: false; error: string }; + +export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | ParseError { + const userJwtRaw = q.get("userJwt"); + if (!userJwtRaw) return { ok: false, error: "Missing userJwt in callback" }; + + let userJwt: Record; + try { + userJwt = JSON.parse(userJwtRaw); + } catch { + return { ok: false, error: "Malformed userJwt payload" }; + } + + const token = userJwt.Token as string | undefined; + if (!token) return { ok: false, error: "userJwt.Token missing" }; + const refresh = (userJwt.RefreshToken as string) || q.get("refreshToken") || null; + const tokenExpiresAtMs = Number(userJwt.TokenExpireAt) || 0; + const refreshExpiresAtMs = Number(userJwt.RefreshExpireAt || q.get("refreshExpireAt")) || 0; + + let info: Record = {}; + const userInfoRaw = q.get("userInfo"); + if (userInfoRaw) { + try { + info = JSON.parse(userInfoRaw); + } catch { + // userInfo is best-effort metadata — fall back to defaults silently. + } + } + + const userId = (info.UserID as string) || ""; + const region = (info.Region as string) || "US-East"; + + return { + ok: true, + record: { + provider: "trae", + authType: "oauth", + accessToken: token, + refreshToken: refresh, + expiresAt: tokenExpiresAtMs ? new Date(tokenExpiresAtMs).toISOString() : null, + email: (info.NonPlainTextEmail as string) || null, + providerSpecificData: { + userId, + tenantId: (info.TenantID as string) || "", + bizUserId: userId, + userUniqueId: userId, + webId: userId, + scope: "marscode-us", + tenant: "marscode", + region, + aiRegion: (info.AIRegion as string) || region, + host: q.get("host") || "https://api-us-east.trae.ai", + screenName: (info.ScreenName as string) || null, + clientId: (userJwt.ClientID as string) || "en1oxy7wnw8j9n", + refreshExpireAt: refreshExpiresAtMs || null, + authMethod: "oauth_callback", + }, + testStatus: "active", + }, + }; +} diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts new file mode 100644 index 0000000000..b2a0954af6 --- /dev/null +++ b/src/app/authorize/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { parseTraeCallbackQuery } from "./parseCallback"; + +/** + * GET /authorize + * + * Loopback callback for the Trae SOLO desktop OAuth flow (auth_from=solo). + * Trae's authorize server validates that auth_callback_url ends with the + * literal `/authorize` path — any other path makes the page short-circuit + * to "Login Failed". So this handler lives at the app root, not under + * /api/oauth/trae/. The provider tag is implicit (only Trae uses /authorize). + * + * Receives the redirect from https://www.trae.ai/authorization after the user + * confirms login. Trae's auth server packs the entire credential set into + * query parameters (no separate token-exchange HTTP call exists): + * + * userJwt — JSON string with { ClientID, Token, RefreshToken, TokenExpireAt, + * RefreshExpireAt, TokenExpireDuration } + * userInfo — JSON string with { UserID, TenantID, Region, AIRegion, ... } + * refreshToken, loginTraceID, host, refreshExpireAt, userRegion, scope — flat fields + * + * We parse the bundle, persist a connection via `createProviderConnection` + * (which encrypts the token), and return an HTML page that postMessages the + * opening window before closing itself — that's how TraeAuthModal knows + * the import succeeded. + * + * State validation: the caller passes its UUID as `login_trace_id` in the + * authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies + * the echoed state before trusting the postMessage. + */ +function htmlClose(message: Record): NextResponse { + // Embedding values: only emit the small/sanitized status payload — never the + // raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on + // this same port rather than "*": Trae forces the callback onto 127.0.0.1, + // but the dashboard opener is usually on localhost, so a single + // window.location.origin target would silently drop the message. Restricting + // to the two known loopback hosts keeps it secure (CWE-359) and working. + const safe = JSON.stringify({ + type: "trae-oauth-callback", + ...message, + }).replace(/ +

Trae authorization ${message.success ? "✓" : "failed"}

+

${message.success ? "You can close this window." : "Return to the dashboard."}

+ + `, + { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } } + ); +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const q = url.searchParams; + const parsed = parseTraeCallbackQuery(q); + if (!parsed.ok) { + return htmlClose({ success: false, error: parsed.error }); + } + try { + const connection: any = await createProviderConnection(parsed.record); + return htmlClose({ + success: true, + connectionId: connection.id, + loginTraceId: q.get("loginTraceID") || null, + }); + } catch (err: any) { + console.error("[trae callback] error:", err); + return htmlClose({ success: false, error: "Internal error during callback" }); + } +} diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bca7352543..e0b937b16c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1612,6 +1612,11 @@ "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream-Standardverhalten", + "streamDefaultModeDesc": "Steuert fehlende `stream`-Angaben für diesen Schlüssel. Im JSON-Modus werden nicht-streamende Antworten zurückgegeben, außer der Client fordert SSE explizit an.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON-kompatibel", + "streamDefaultBadge": "JSON-Standardstream", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 13e8e3658d..63f61a4a71 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1672,6 +1672,11 @@ "endpointsRestricted": "Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream Default Compatibility", + "streamDefaultModeDesc": "Controls omitted `stream` flags for this key. JSON mode returns non-streaming responses unless the client explicitly requests SSE.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON Compatible", + "streamDefaultBadge": "JSON stream default", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 32807e9a76..9d085bf6bd 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -23,7 +23,7 @@ "active": "Активний", "inactive": "Неактивний", "noData": "Немає даних", - "nothingHere": "Nothing here yet", + "nothingHere": "Поки що нічого", "configure": "Налаштувати", "manage": "Керувати", "name": "Ім'я", @@ -37,7 +37,7 @@ "time": "час", "details": "Подробиці", "created": "Створено", - "lastUsed": "Last Refreshed", + "lastUsed": "Востаннє оновлено", "loadMore": "Завантажити більше", "noResults": "Результатів не знайдено", "reloadPage": "Перезавантажити сторінку", @@ -60,8 +60,8 @@ "note": "Примітка", "free": "безкоштовно", "skipToContent": "Перейти до вмісту", - "maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.", - "maintenanceServerUnreachable": "Server is unreachable. Reconnecting...", + "maintenanceServerIssues": "Сервер працює нестабільно. Деякі функції можуть бути недоступні.", + "maintenanceServerUnreachable": "Сервер недоступний. Перепідключення...", "accept": "прийняти", "accountId": "ID облікового запису", "alias": "Псевдонім", @@ -140,36 +140,36 @@ "Failed to reset pricing": "Не вдалося скинути ціни", "apikey": "Ключ API", "http": "HTTP", - "goToDashboard": "Go to Dashboard", - "checkSystemStatus": "Check System Status", - "selectModel": "Select Model", - "combos": "Combos", - "noModelsFound": "No models found", - "clear": "Clear", - "done": "Done", - "errorOccurred": "Error Occurred", - "comboDeleted": "Combo Deleted", - "hide": "Hide", - "creating": "Creating", - "comboCreated": "Combo Created", - "swapFormats": "Swap Formats", - "daysAgo": "Days Ago", - "retries": "Retries", - "errorDuringRestore": "Error During Restore", - "eventsAppearHint": "Events Appear Hint", - "noLockouts": "No Lockouts", - "webSearchDesc": "Web Search Desc", - "audioProvidersHeading": "Audio Providers Heading", + "goToDashboard": "Перейти до панелі", + "checkSystemStatus": "Перевірити стан системи", + "selectModel": "Виберіть модель", + "combos": "Комбінації", + "noModelsFound": "Моделі не знайдено", + "clear": "Очистити", + "done": "Готово", + "errorOccurred": "Сталася помилка", + "comboDeleted": "Комбінацію видалено", + "hide": "Приховати", + "creating": "Створення", + "comboCreated": "Комбінацію створено", + "swapFormats": "Поміняти формати", + "daysAgo": "{count} днів тому", + "retries": "Повтори", + "errorDuringRestore": "Помилка під час відновлення", + "eventsAppearHint": "Події з'являться тут", + "noLockouts": "Блокувань немає", + "webSearchDesc": "Пошук в Інтернеті", + "audioProvidersHeading": "Аудіопровайдери", "cloudAgentProviders": "Постачальники хмарних агентів", - "minutesAgo": "Minutes Ago", + "minutesAgo": "{count} хв тому", "a": "A", - "liveAutoRefreshing": "Live Auto Refreshing", - "webSearch": "Web Search", - "anthropicPrefixPlaceholder": "Anthropic Prefix Placeholder", - "addModelToCombo": "Add Model To Combo", - "failedToLoad": "Failed To Load", - "categoryMedia": "Category Media", - "enableCloud": "Enable Cloud", + "liveAutoRefreshing": "Наживо · автооновлення", + "webSearch": "Веб-пошук", + "anthropicPrefixPlaceholder": "Префікс Anthropic", + "addModelToCombo": "Додати модель до комбінації", + "failedToLoad": "Не вдалося завантажити", + "categoryMedia": "Медіа", + "enableCloud": "Увімкнути хмару", "expirationBannerExpiringSoon": "Expiration Banner Expiring Soon", "retry": "Retry", "embeddings": "Embeddings", @@ -856,14 +856,14 @@ "media": "Медіа", "settings": "Налаштування", "translator": "Перекладач", - "playground": "Playground", - "searchTools": "Search Tools", + "playground": "Пісочниця", + "searchTools": "Інструменти пошуку", "agents": "Агенти", "cloudAgents": "Хмарні агенти", - "memory": "Memory", - "skills": "Skills", + "memory": "Пам'ять", + "skills": "Навички", "omniSkills": "OmniSkills", - "agentSkills": "AgentSkills", + "agentSkills": "Навички агентів", "docs": "документи", "issues": "Питання", "endpoints": "Кінцеві точки", @@ -888,180 +888,180 @@ "debug": "Налагодження", "system": "система", "help": "Довідка", - "primarySection": "Main", + "primarySection": "Головне", "cliSection": "CLI", - "debugSection": "Debug", - "systemSection": "System", - "helpSection": "Help", + "debugSection": "Налагодження", + "systemSection": "Система", + "helpSection": "Довідка", "serverDisconnected": "Сервер відключено", "serverDisconnectedMsg": "Проксі-сервер зупинено або перезавантажується.", "expandSidebar": "Розгорнути бічну панель", "collapseSidebar": "Згорнути бічну панель", - "themes": "Themes", - "presetColors": "Popular colors", - "createTheme": "Create theme", - "chooseColor": "Pick one color", - "themeCoral": "Coral", - "themeBlue": "Blue", - "themeRed": "Red", - "themeGreen": "Green", - "themeViolet": "Violet", - "themeOrange": "Orange", - "themeCyan": "Cyan", + "themes": "Теми", + "presetColors": "Популярні кольори", + "createTheme": "Створити тему", + "chooseColor": "Виберіть колір", + "themeCoral": "Кораловий", + "themeBlue": "Синій", + "themeRed": "Червоний", + "themeGreen": "Зелений", + "themeViolet": "Фіолетовий", + "themeOrange": "Помаранчевий", + "themeCyan": "Бірюзовий", "cliToolsShort": "Інструменти", - "cache": "Cache", - "cacheShort": "Cache", - "batch": "Batch Jobs", - "themeSystem": "Theme System", - "whitelabelingDesc": "Whitelabeling Desc", - "switchThemes": "Switch Themes", - "themeAccentDesc": "Theme Accent Desc", - "uploadFavicon": "Upload Favicon", - "themeDark": "Theme Dark", - "customLogoDesc": "Custom Logo Desc", - "sidebarVisibilityToggle": "Sidebar Visibility Toggle", - "themeAccent": "Theme Accent", - "resetFavicon": "Reset Favicon", - "whitelabeling": "Whitelabeling", - "darkMode": "Dark Mode", - "uploadLogo": "Upload Logo", - "themeLight": "Theme Light", - "appName": "App Name", - "appNameDesc": "App Name Desc", - "resetLogo": "Reset Logo", - "customFavicon": "Custom Favicon", - "hideHealthLogs": "Hide Health Logs", - "customLogo": "Custom Logo", - "appearance": "Appearance", - "themeSelectionAria": "Theme Selection Aria", - "themeCreate": "Theme Create", - "customFaviconDesc": "Custom Favicon Desc", - "logoPreview": "Logo Preview", - "themeCustom": "Theme Custom", - "hideHealthLogsDesc": "Hide Health Logs Desc", - "faviconPreview": "Favicon Preview", - "changelog": "Changelog", - "contextSection": "Context & Cache", + "cache": "Кеш", + "cacheShort": "Кеш", + "batch": "Пакетні завдання", + "themeSystem": "Системна тема", + "whitelabelingDesc": "Білий лейбл", + "switchThemes": "Перемкнути теми", + "themeAccentDesc": "Акцентний колір теми", + "uploadFavicon": "Завантажити favicon", + "themeDark": "Темна тема", + "customLogoDesc": "Користувацький логотип", + "sidebarVisibilityToggle": "Видимість бічної панелі", + "themeAccent": "Акцент теми", + "resetFavicon": "Скинути favicon", + "whitelabeling": "Білий лейбл", + "darkMode": "Темний режим", + "uploadLogo": "Завантажити логотип", + "themeLight": "Світла тема", + "appName": "Назва застосунку", + "appNameDesc": "Назва застосунку", + "resetLogo": "Скинути логотип", + "customFavicon": "Користувацький favicon", + "hideHealthLogs": "Приховати логи стану", + "customLogo": "Користувацький логотип", + "appearance": "Зовнішній вигляд", + "themeSelectionAria": "Вибір теми", + "themeCreate": "Створити тему", + "customFaviconDesc": "Користувацький favicon", + "logoPreview": "Перегляд логотипа", + "themeCustom": "Користувацька тема", + "hideHealthLogsDesc": "Приховати логи стану", + "faviconPreview": "Перегляд favicon", + "changelog": "Журнал змін", + "contextSection": "Контекст і кеш", "contextCaveman": "Caveman", "contextRtk": "RTK", - "contextCombos": "Engine Combos", - "routingSection": "Routing", - "protocolsSection": "Protocols", - "agentsAiSection": "Agents & AI", - "cacheContextSection": "Cache & Context", - "analyticsSection": "Analytics", - "costsSection": "Costs", - "monitoringSection": "Monitoring", - "auditSecuritySection": "Audit & Security", - "devtoolsSection": "Dev Tools", - "configurationSection": "Configuration", - "aiFeaturesSection": "AI Features", + "contextCombos": "Комбінації двигунів", + "routingSection": "Маршрутизація", + "protocolsSection": "Протоколи", + "agentsAiSection": "Агенти та AI", + "cacheContextSection": "Кеш і контекст", + "analyticsSection": "Аналітика", + "costsSection": "Витрати", + "monitoringSection": "Моніторинг", + "auditSecuritySection": "Аудит і безпека", + "devtoolsSection": "Інструменти розробника", + "configurationSection": "Налаштування", + "aiFeaturesSection": "Функції AI", "mcp": "MCP", "a2a": "A2A", - "apiEndpoints": "API Endpoints", - "batchFiles": "Files", - "analyticsEvals": "Evals", - "analyticsSearch": "Search", - "analyticsUtilization": "Utilization", - "analyticsComboHealth": "Combo Health", - "analyticsCompression": "Compression", - "costsBudget": "Budget", - "costsQuotaShare": "Quota Sharing", - "costsPricing": "Pricing", - "logsProxy": "Proxy Logs", - "logsConsole": "Console", - "logsActivity": "Activity", - "auditMcp": "MCP Audit", + "apiEndpoints": "API-ендпоінти", + "batchFiles": "Файли", + "analyticsEvals": "Оцінювання", + "analyticsSearch": "Пошук", + "analyticsUtilization": "Завантаженість", + "analyticsComboHealth": "Стан комбінацій", + "analyticsCompression": "Стиснення", + "costsBudget": "Бюджет", + "costsQuotaShare": "Спільні квоти", + "costsPricing": "Ціноутворення", + "logsProxy": "Логи проксі", + "logsConsole": "Консоль", + "logsActivity": "Активність", + "auditMcp": "Аудит MCP", "auditA2a": "Аудит A2A", - "settingsGeneral": "General", - "settingsAppearance": "Appearance", - "settingsAi": "AI Settings", - "settingsSecurity": "Security", + "settingsGeneral": "Загальні", + "settingsAppearance": "Зовнішній вигляд", + "settingsAi": "Налаштування AI", + "settingsSecurity": "Безпека", "settingsFeatureFlags": "Прапори функцій", "settingsAuthz": "__MISSING__:Authz", - "settingsRouting": "Routing", - "settingsResilience": "Resilience", - "settingsAdvanced": "Advanced", + "settingsRouting": "Маршрутизація", + "settingsResilience": "Стійкість", + "settingsAdvanced": "Розширені", "omniProxySection": "OmniProxy", - "quotaTracker": "Provider Quota", - "providerQuota": "Provider Quota", - "runtime": "Runtime", - "consoleLogs": "Console Logs", - "globalRouting": "Global Routing", - "mitmProxy": "MITM Proxy", + "quotaTracker": "Квоти провайдерів", + "providerQuota": "Квоти провайдерів", + "runtime": "Середовище виконання", + "consoleLogs": "Логи консолі", + "globalRouting": "Глобальна маршрутизація", + "mitmProxy": "MITM-проксі", "oneProxy": "1Proxy", - "agenticFeaturesSection": "Agentic Features", - "otherFeaturesSection": "Other Features", - "compressionContextGroup": "Compression Context", - "toolsGroup": "Tools", - "integrationsGroup": "Integrations", - "proxyGroup": "Proxy", - "costsParametersGroup": "Costs Parameters", - "auditGroup": "Audit", - "batchGroup": "Batch", - "homeSubtitle": "Dashboard overview", - "providersSubtitle": "Manage AI providers", - "quotaTrackerSubtitle": "Track usage limits", - "providerQuotaSubtitle": "Track provider usage limits", - "runtimeSubtitle": "Realtime resilience & sessions", - "contextCombosSubtitle": "Combine compression engines", - "cliToolsSubtitle": "Configure CLI runtimes", - "agentsSubtitle": "Manage local agents", - "cloudAgentsSubtitle": "Manage cloud-based agents", - "apiEndpointsSubtitle": "Expose custom endpoints", - "proxySubtitle": "HTTP proxy settings", - "mitmProxySubtitle": "MITM interception", - "oneProxySubtitle": "Public proxy gateway", - "leaderboard": "Leaderboard", - "profile": "Profile", - "tokens": "Tokens", - "leaderboardSubtitle": "Rankings and achievements", - "profileSubtitle": "Account and preferences", - "tokensSubtitle": "Token usage and budgets", - "usageSubtitle": "Traffic and usage stats", - "analyticsComboHealthSubtitle": "Combo target reliability", - "analyticsUtilizationSubtitle": "Provider utilization", - "costsSubtitle": "Spend breakdown", - "cacheSubtitle": "Cache hit rates", - "analyticsCompressionSubtitle": "Token savings stats", - "analyticsSearchSubtitle": "Search tool analytics", - "analyticsEvalsSubtitle": "Eval suite results", - "logsSubtitle": "Application logs", - "logsProxySubtitle": "Proxy traffic logs", - "consoleLogsSubtitle": "Console output", - "logsActivitySubtitle": "User activity log", - "healthSubtitle": "System health check", - "costsPricingSubtitle": "Per-model pricing rules", - "costsBudgetSubtitle": "Budget limits", - "costsQuotaShareSubtitle": "Share provider quotas across keys", - "auditLogSubtitle": "Authorization audit", - "auditMcpSubtitle": "MCP server audit", - "auditA2aSubtitle": "A2A protocol audit", - "translatorSubtitle": "Format conversion", - "playgroundSubtitle": "Test prompts live", - "searchToolsSubtitle": "Search tool registry", - "memorySubtitle": "Persistent agent memory", - "omniSkillsSubtitle": "Sandbox skill registry", - "agentSkillsSubtitle": "A2A skill registry", - "mcpSubtitle": "MCP server controls", - "a2aSubtitle": "A2A protocol server", - "mediaSubtitle": "Cached media files", - "batchFilesSubtitle": "Batch input/output files", - "settingsSubtitle": "All settings", - "settingsGeneralSubtitle": "App basics", - "settingsAppearanceSubtitle": "Theme and layout", - "settingsAiSubtitle": "AI behavior defaults", - "globalRoutingSubtitle": "Global routing rules", - "settingsResilienceSubtitle": "Retries and breakers", - "settingsAdvancedSubtitle": "Power user options", - "settingsSecuritySubtitle": "Auth and encryption", + "agenticFeaturesSection": "Агентні функції", + "otherFeaturesSection": "Інші функції", + "compressionContextGroup": "Стиснення контексту", + "toolsGroup": "Інструменти", + "integrationsGroup": "Інтеграції", + "proxyGroup": "Проксі", + "costsParametersGroup": "Параметри витрат", + "auditGroup": "Аудит", + "batchGroup": "Пакетна обробка", + "homeSubtitle": "Огляд панелі", + "providersSubtitle": "Керування AI-провайдерами", + "quotaTrackerSubtitle": "Відстеження лімітів", + "providerQuotaSubtitle": "Відстеження лімітів провайдерів", + "runtimeSubtitle": "Стійкість і сесії в реальному часі", + "contextCombosSubtitle": "Комбінування двигунів стиснення", + "cliToolsSubtitle": "Налаштування CLI-середовищ", + "agentsSubtitle": "Керування локальними агентами", + "cloudAgentsSubtitle": "Керування хмарними агентами", + "apiEndpointsSubtitle": "Власні API-ендпоінти", + "proxySubtitle": "Налаштування HTTP-проксі", + "mitmProxySubtitle": "Перехоплення MITM", + "oneProxySubtitle": "Публічний проксі-шлюз", + "leaderboard": "Рейтинг", + "profile": "Профіль", + "tokens": "Токени", + "leaderboardSubtitle": "Рейтинги та досягнення", + "profileSubtitle": "Акаунт і налаштування", + "tokensSubtitle": "Використання токенів і бюджети", + "usageSubtitle": "Статистика трафіку та використання", + "analyticsComboHealthSubtitle": "Надійність маршрутів комбінацій", + "analyticsUtilizationSubtitle": "Завантаженість провайдерів", + "costsSubtitle": "Розподіл витрат", + "cacheSubtitle": "Коефіцієнт влучань у кеш", + "analyticsCompressionSubtitle": "Економія токенів", + "analyticsSearchSubtitle": "Аналітика інструменту пошуку", + "analyticsEvalsSubtitle": "Результати оцінювань", + "logsSubtitle": "Логи застосунку", + "logsProxySubtitle": "Логи трафіку проксі", + "consoleLogsSubtitle": "Вивід консолі", + "logsActivitySubtitle": "Журнал дій користувача", + "healthSubtitle": "Перевірка стану системи", + "costsPricingSubtitle": "Правила цін для моделей", + "costsBudgetSubtitle": "Бюджетні ліміти", + "costsQuotaShareSubtitle": "Спільне використання квот між ключами", + "auditLogSubtitle": "Аудит авторизації", + "auditMcpSubtitle": "Аудит MCP-сервера", + "auditA2aSubtitle": "Аудит протоколу A2A", + "translatorSubtitle": "Перетворення форматів", + "playgroundSubtitle": "Тестування промптів наживо", + "searchToolsSubtitle": "Реєстр інструментів пошуку", + "memorySubtitle": "Постійна пам'ять агента", + "omniSkillsSubtitle": "Реєстр пісочних навичок", + "agentSkillsSubtitle": "Реєстр навичок A2A", + "mcpSubtitle": "Керування MCP-сервером", + "a2aSubtitle": "Сервер протоколу A2A", + "mediaSubtitle": "Кешовані медіафайли", + "batchFilesSubtitle": "Файли вводу/виводу пакетної обробки", + "settingsSubtitle": "Усі налаштування", + "settingsGeneralSubtitle": "Основи застосунку", + "settingsAppearanceSubtitle": "Тема та компонування", + "settingsAiSubtitle": "Налаштування поведінки AI", + "globalRoutingSubtitle": "Глобальні правила маршрутизації", + "settingsResilienceSubtitle": "Повтори та запобіжники", + "settingsAdvancedSubtitle": "Параметри для досвідчених користувачів", + "settingsSecuritySubtitle": "Автентифікація та шифрування", "settingsFeatureFlagsSubtitle": "Перемикання можливостей системи", - "settingsSidebar": "Sidebar", - "settingsSidebarSubtitle": "Customize sidebar layout", + "settingsSidebar": "Бічна панель", + "settingsSidebarSubtitle": "Налаштування компонування бічної панелі", "settingsAuthzSubtitle": "__MISSING__:Route inventory and bypass policy", - "docsSubtitle": "Documentation", - "issuesSubtitle": "Report a bug", - "changelogSubtitle": "Release notes" + "docsSubtitle": "Документація", + "issuesSubtitle": "Повідомити про помилку", + "changelogSubtitle": "Примітки до випуску" }, "webhooks": { "title": "Веб-хуки", @@ -1288,62 +1288,62 @@ "cliToolsDescription": "Налаштувати інструменти CLI", "home": "додому", "homeDescription": "Ласкаво просимо до OmniRoute", - "endpoint": "Endpoints", - "endpointDescription": "Manage proxy endpoints, MCP, A2A, and API endpoints", + "endpoint": "Ендпоінти", + "endpointDescription": "Керування проксі-ендпоінтами, MCP, A2A та API-ендпоінтами", "mcp": "MCP", - "mcpDescription": "Model Context Protocol server management and tools", + "mcpDescription": "Керування сервером і інструментами Model Context Protocol", "a2a": "A2A", - "a2aDescription": "Agent-to-Agent protocol tasks and observability", + "a2aDescription": "Задачі та спостереження протоколу Agent-to-Agent", "settings": "Налаштування", "settingsDescription": "Керуйте своїми вподобаннями", "openaiCompatible": "Сумісність з OpenAI", "anthropicCompatible": "Антропна сумісність", - "media": "Media", - "mediaDescription": "Generate images, videos, and music", - "themes": "Themes", - "themesDescription": "Choose a color theme for the whole dashboard panel", - "costsDescription": "Track spending, analyze trends, and manage your AI budget across all providers", - "cacheDescription": "Monitor provider prompt cache efficiency and local semantic response reuse.", - "limitsDescription": "Configure rate limits and quotas per API key and provider", - "runtimeDescription": "Live runtime observability — circuit breakers, cooldowns, model lockouts, sessions and quota alerts", - "apiManagerDescription": "Manage API keys and access control for your OmniRoute instance", - "batchDescription": "Process large volumes of requests asynchronously with batched API calls", - "contextCavemanDescription": "Rule-based message compression, language packs, analytics and output mode controls.", - "contextRtkDescription": "Command-aware compression for tool output, terminal logs and build results.", - "contextCombosDescription": "Define how engines are combined for different routing scenarios.", - "changelogDescription": "Stay up to date with the latest platform features and announcements.", - "agentsDescription": "Manage and configure AI agent tools: Codex, Devin, Jules, and custom agents", - "cloudAgentsDescription": "Orchestrate cloud-based AI agents with live task tracking and plan approval", - "memoryDescription": "Persistent conversational memory with semantic search and FTS5 full-text indexing", - "skillsDescription": "Install and manage sandbox skills for automated prompt and tool execution", - "agentSkillsDescription": "Agent-ready skills catalog with one-click URL copy for AI client integration", - "translatorDescription": "Translate and test prompts across API formats: OpenAI ↔ Claude ↔ Gemini", - "playgroundDescription": "Test prompts interactively with live provider responses and format inspection", - "searchToolsDescription": "Search analytics, provider breakdown, cache hit rates, and cost tracking", - "logsDescription": "Real-time request logs, error traces, and streaming event inspector", - "auditDescription": "Compliance audit trail of API key usage, MCP tool calls, and policy events", - "webhooksDescription": "Configure webhook endpoints to receive real-time event notifications", - "healthDescription": "System health overview: providers, circuit breakers, rate limits, and database", - "proxyDescription": "Configure upstream proxy settings for outbound provider connections", - "apiEndpointsDescription": "Manage custom API endpoint configurations and routing overrides", - "batchFilesDescription": "Browse and manage batch job output files and results", - "analyticsEvalsDescription": "Model evaluation results and performance benchmarks", - "analyticsSearchDescription": "Search query analytics, cache hit rates, and cost tracking", - "analyticsUtilizationDescription": "Provider utilization metrics and capacity planning", - "analyticsComboHealthDescription": "Real-time health and performance of combo routing configurations", - "analyticsCompressionDescription": "Context compression analytics and token savings", - "costsBudgetDescription": "Budget limits and spending alerts per API key and provider", - "costsPricingDescription": "Custom pricing configuration for token cost calculations", - "logsProxyDescription": "Upstream proxy request logs and traffic inspection", - "logsConsoleDescription": "Application console output and debug logs", - "logsActivityDescription": "Audit trail of user actions and system events", - "auditMcpDescription": "MCP tool invocation audit trail and compliance records", + "media": "Медіа", + "mediaDescription": "Генерація зображень, відео та музики", + "themes": "Теми", + "themesDescription": "Виберіть колірну тему для всієї панелі керування", + "costsDescription": "Відстеження витрат, аналіз трендів і керування бюджетом AI у всіх провайдерах", + "cacheDescription": "Моніторинг ефективності кешу промптів провайдерів і локального семантичного повторного використання відповідей.", + "limitsDescription": "Налаштування лімітів швидкості та квот для API-ключів і провайдерів", + "runtimeDescription": "Спостереження за середовищем у реальному часі — запобіжники, охолодження, блокування моделей, сесії та сповіщення про квоти", + "apiManagerDescription": "Керування API-ключами та контролем доступу для вашого екземпляра OmniRoute", + "batchDescription": "Асинхронна обробка великих обсягів запитів через пакетні API-виклики", + "contextCavemanDescription": "Стиснення повідомлень на основі правил, мовні пакети, аналітика та керування режимом виводу.", + "contextRtkDescription": "Стиснення з урахуванням команд для виводу інструментів, логів терміналу та результатів збірок.", + "contextCombosDescription": "Визначте, як двигуни комбінуються для різних сценаріїв маршрутизації.", + "changelogDescription": "Будьте в курсі останніх функцій платформи та анонсів.", + "agentsDescription": "Керування та налаштування AI-агентів: Codex, Devin, Jules та користувацьких агентів", + "cloudAgentsDescription": "Оркестрація хмарних AI-агентів із відстеженням задач у реальному часі та затвердженням планів", + "memoryDescription": "Постійна діалогова пам'ять із семантичним пошуком та повнотекстовим індексом FTS5", + "skillsDescription": "Встановлення та керування пісочними навичками для автоматичного виконання промптів та інструментів", + "agentSkillsDescription": "Каталог готових для агентів навичок з копіюванням URL для інтеграції AI-клієнтів", + "translatorDescription": "Переклад і тестування промптів між API-форматами: OpenAI ↔ Claude ↔ Gemini", + "playgroundDescription": "Інтерактивне тестування промптів із живими відповідями провайдерів та інспекцією форматів", + "searchToolsDescription": "Аналітика пошуку, розподіл за провайдерами, коефіцієнт влучань у кеш та відстеження вартості", + "logsDescription": "Логи запитів у реальному часі, трасування помилок та інспектор потокових подій", + "auditDescription": "Аудит відповідності: використання API-ключів, виклики MCP-інструментів та події політик", + "webhooksDescription": "Налаштування ендпоінтів webhook для отримання сповіщень про події в реальному часі", + "healthDescription": "Огляд стану системи: провайдери, запобіжники, ліміти швидкості та база даних", + "proxyDescription": "Налаштування вихідного проксі для з'єднань з провайдерами", + "apiEndpointsDescription": "Керування користувацькими конфігураціями API-ендпоінтів і перевизначеннями маршрутизації", + "batchFilesDescription": "Перегляд і керування файлами результатів пакетних завдань", + "analyticsEvalsDescription": "Результати оцінювання моделей та бенчмарки продуктивності", + "analyticsSearchDescription": "Аналітика пошукових запитів, коефіцієнт влучань у кеш та відстеження вартості", + "analyticsUtilizationDescription": "Метрики завантаженості провайдерів і планування потужностей", + "analyticsComboHealthDescription": "Стан і продуктивність комбінованих конфігурацій маршрутизації в реальному часі", + "analyticsCompressionDescription": "Аналітика стиснення контексту та економії токенів", + "costsBudgetDescription": "Бюджетні ліміти та сповіщення про витрати для API-ключів і провайдерів", + "costsPricingDescription": "Користувацькі налаштування цін для розрахунку вартості токенів", + "logsProxyDescription": "Логи запитів проксі та інспекція трафіку", + "logsConsoleDescription": "Вивід консолі застосунку та логи налагодження", + "logsActivityDescription": "Аудит дій користувачів і системних подій", + "auditMcpDescription": "Аудит викликів MCP-інструментів та записи відповідності", "auditA2a": "Аудит A2A", "auditA2aDescription": "Журнал аудиту виконання завдань A2A, переходи станів і записи викликів навичок", - "settingsGeneralDescription": "Storage, database, and general instance configuration", - "settingsAppearanceDescription": "Theme, branding, and visual customization", - "settingsAiDescription": "AI behaviors, thinking budgets, vision, and memory settings", - "settingsSecurityDescription": "Authentication, authorization, and access control settings", + "settingsGeneralDescription": "Сховище, база даних та загальні налаштування екземпляра", + "settingsAppearanceDescription": "Тема, брендинг та візуальне налаштування", + "settingsAiDescription": "Поведінка AI, бюджети мислення, зір та налаштування пам'яті", + "settingsSecurityDescription": "Налаштування автентифікації, авторизації та контролю доступу", "featureFlags": "Прапори функцій", "featureFlagsDescription": "Можливості системи керування та експериментальні особливості", "featureFlagsActive": "{count} активний", @@ -1367,11 +1367,11 @@ "featureFlagsNoResults": "Жоден прапорець не відповідає вашому пошуку", "featureFlagsSaved": "Прапор оновлено", "featureFlagsError": "Не вдалося оновити прапор", - "settingsRoutingDescription": "Routing rules, model aliases, combo defaults, and degradation settings", - "settingsResilienceDescription": "Circuit breaker, retry, and fallback configuration", - "settingsAdvancedDescription": "Advanced payload rules, request limits, and proxy API settings", - "mitmProxyDescription": "Configure MITM proxy settings for traffic inspection and debugging", - "oneProxyDescription": "Configure 1Proxy settings for advanced proxy chaining" + "settingsRoutingDescription": "Правила маршрутизації, аліаси моделей, налаштування комбо та деградації", + "settingsResilienceDescription": "Налаштування запобіжника, повторів та резервних шляхів", + "settingsAdvancedDescription": "Розширені правила запитів, ліміти та налаштування API проксі", + "mitmProxyDescription": "Налаштування MITM-проксі для інспекції трафіку та налагодження", + "oneProxyDescription": "Налаштування 1Proxy для розширеного ланцюгування проксі" }, "home": { "quickStart": "Швидкий старт", @@ -1607,9 +1607,9 @@ "keyCreatedNote": "Скопіюйте та збережіть цей ключ зараз — він більше не відображатиметься.", "done": "Готово", "savePermissions": "Зберегти дозволи", - "endpointRestrictions": "__MISSING__:Allowed Endpoints", - "allEndpointsAllowed": "__MISSING__:This key can access all API endpoints.", - "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", + "endpointRestrictions": "Обмеження ендпоінтів", + "allEndpointsAllowed": "Дозволено всі ендпоінти", + "endpointsRestricted": "{count, plural, one {# ендпоінт обмежено} few {# ендпоінти обмежено} many {# ендпоінтів обмежено} other {# ендпоінта обмежено}}", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", "keyActive": "Key Active", @@ -5472,13 +5472,13 @@ "vercelRelayFreeTierNote": "__MISSING__:Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.", "vercelRelayDeploying": "__MISSING__:Deploying...", "vercelRelayDeploy": "__MISSING__:Deploy", - "homePinProviderQuotaToHome": "Pin Information to Home Page", - "homeProviderQuotaLimits": "Provider Quota Limits", - "homeProviderQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.", - "homeQuickStart": "Quick Start", - "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", - "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homePinProviderQuotaToHome": "Закріпити інформацію на головній сторінці", + "homeProviderQuotaLimits": "Ліміти квот провайдерів", + "homeProviderQuotaLimitsDesc": "Закріпити контейнер стану квот провайдерів (з кнопкою «Оновити все») вгорі головної сторінки.", + "homeQuickStart": "Швидкий старт", + "homeQuickStartDesc": "Показувати панель швидкого старту на головній сторінці.", + "homeProviderTopology": "Топологія провайдерів", + "homeProviderTopologyDesc": "Показувати топологію провайдерів на головній сторінці." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 12d2add450..cbeea5306d 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -618,6 +618,17 @@ export async function getComboBuilderOptions(): Promise" misroutes to the opencode-zen api-key tier + // (manual ALIAS_TO_PROVIDER_ID override), while "oc/" resolves to the + // no-auth "opencode" provider. Rewrite qualifiedModel to the alias prefix. + const routingPrefix = noAuthProvider.alias || providerId; + if (routingPrefix !== providerId) { + for (const opt of modelMap.values()) { + opt.qualifiedModel = `${routingPrefix}/${opt.id}`; + } + } + const displayName = (providerEntryName(providerId) || getProviderDisplayName(providerId, null) || providerId) as string; diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 301214c7d0..aa619c669d 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -61,6 +61,7 @@ interface ApiKeyMetadata { isBanned: boolean; keyHash: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } interface ApiKeyRow extends JsonRecord { @@ -87,6 +88,8 @@ interface ApiKeyRow extends JsonRecord { accessSchedule?: unknown; rate_limits?: unknown; rateLimits?: unknown; + stream_default_mode?: unknown; + streamDefaultMode?: unknown; } interface StatementLike { @@ -125,6 +128,7 @@ interface ApiKeyView extends JsonRecord { isBanned?: boolean; expiresAt?: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } // LRU cache for API key validation (valid keys only) @@ -161,6 +165,7 @@ const API_KEY_COLUMN_FALLBACKS = [ { name: "key_hash", definition: "key_hash TEXT" }, { name: "allowed_endpoints", definition: "allowed_endpoints TEXT" }, { name: "allowed_quotas", definition: "allowed_quotas TEXT NOT NULL DEFAULT '[]'" }, + { name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" }, ] as const; // Cache for model permission checks @@ -360,7 +365,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -407,6 +412,7 @@ export async function getApiKeys() { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -432,6 +438,7 @@ export async function getApiKeyById(id: string) { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -576,6 +583,10 @@ function parseIsBanned(value: unknown): boolean { return value === 1 || value === "1" || value === true; } +function parseStreamDefaultMode(value: unknown): "legacy" | "json" { + return value === "json" ? "json" : "legacy"; +} + async function hashKey(key: string): Promise { if (!key || typeof key !== "string") return ""; // CodeQL: This is intentionally SHA-256, NOT password hashing. API keys are @@ -686,6 +697,7 @@ export async function updateApiKeyPermissions( maxSessions?: number | null; scopes?: string[] | null; allowedEndpoints?: string[] | null; + streamDefaultMode?: "legacy" | "json" | null; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -713,6 +725,8 @@ export async function updateApiKeyPermissions( maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, + streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null }) + .streamDefaultMode, }; if ( @@ -733,7 +747,8 @@ export async function updateApiKeyPermissions( normalized.expiresAt === undefined && (normalized as Record).maxSessions === undefined && (normalized as Record).scopes === undefined && - (normalized as Record).allowedEndpoints === undefined + (normalized as Record).allowedEndpoints === undefined && + (normalized as Record).streamDefaultMode === undefined ) { return false; } @@ -758,6 +773,7 @@ export async function updateApiKeyPermissions( maxSessions?: number; expiresAt?: string | null; scopes?: string; + streamDefaultMode?: "legacy" | "json"; } = { id }; if (normalized.name !== undefined) { @@ -855,13 +871,17 @@ export async function updateApiKeyPermissions( if (allowedEndpointsUpdate !== undefined) { updates.push("allowed_endpoints = @allowedEndpoints"); const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate) - ? (allowedEndpointsUpdate as unknown[]).filter( - (s): s is string => typeof s === "string" - ) + ? (allowedEndpointsUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; (params as Record).allowedEndpoints = JSON.stringify(nextEndpoints); } + const streamDefaultModeUpdate = (normalized as Record).streamDefaultMode; + if (streamDefaultModeUpdate !== undefined) { + updates.push("stream_default_mode = @streamDefaultMode"); + params.streamDefaultMode = parseStreamDefaultMode(streamDefaultModeUpdate); + } + const scopesUpdate = (normalized as Record).scopes; const nextScopes: string[] = Array.isArray(scopesUpdate) ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") @@ -1210,6 +1230,7 @@ export async function getApiKeyMetadata( keyHash: null, scopes: ["manage"], allowedEndpoints: [], + streamDefaultMode: "legacy", }; } @@ -1270,6 +1291,9 @@ export async function getApiKeyMetadata( allowedEndpoints: parseStringList( (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints ), + streamDefaultMode: parseStreamDefaultMode( + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode + ), }; if (!metadata.id) { diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index c09653287f..92a89087ea 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -444,9 +444,15 @@ function isSchemaAlreadyApplied( // mid-file and skip the CREATE INDEX that follows, leaving the index // missing on DBs that re-execute the script after a partial first run. return hasColumn(db, "memories", "needs_reindex"); - case "087": - // Quota groups migration: the table + column are already present when - // group_id exists on quota_pools (ensures the backfill UPDATE also ran). + case "085": + // Retroactive guard for quota_pools migration renumbered from 077 → 085 + // (077 collided with 077_api_key_stream_default_mode). DBs that already + // applied quota_pools under the old 077 number should not re-run as 085. + return hasTable(db, "quota_pools") && hasTable(db, "quota_allocations"); + case "088": + // Quota groups migration (renumbered 087 → 088 on merge into v3.8.8). + // The table + column are already present when group_id exists on + // quota_pools (ensures the backfill UPDATE also ran). return hasTable(db, "quota_groups") && hasColumn(db, "quota_pools", "group_id"); default: return false; diff --git a/src/lib/db/migrations/077_api_key_stream_default_mode.sql b/src/lib/db/migrations/077_api_key_stream_default_mode.sql new file mode 100644 index 0000000000..0c8ffff0e4 --- /dev/null +++ b/src/lib/db/migrations/077_api_key_stream_default_mode.sql @@ -0,0 +1,3 @@ +-- 077: Per-API-key default for omitted chat completion stream flags. + +ALTER TABLE api_keys ADD COLUMN stream_default_mode TEXT NOT NULL DEFAULT 'legacy'; diff --git a/src/lib/db/migrations/077_quota_pools.sql b/src/lib/db/migrations/085_quota_pools.sql similarity index 67% rename from src/lib/db/migrations/077_quota_pools.sql rename to src/lib/db/migrations/085_quota_pools.sql index 255a126071..d5fb173f4a 100644 --- a/src/lib/db/migrations/077_quota_pools.sql +++ b/src/lib/db/migrations/085_quota_pools.sql @@ -1,4 +1,12 @@ --- Migration 073: quota_pools + quota_allocations +-- Migration 085: quota_pools + quota_allocations +-- +-- Renumbered from 077 → 085 (#2900 sibling fix): 077 collided with +-- 077_api_key_stream_default_mode.sql, which made getMigrationFiles() throw a +-- version-collision error and blocked getDbInstance() at startup. quota_pools +-- has no dependents (no other migration references these tables) and is fully +-- idempotent, so it can safely move to the next free number. DBs that already +-- applied it under the old 077 number are guarded in isSchemaAlreadyApplied +-- (case "085"). -- -- Creates the two tables that persist quota-sharing pools and per-API-key -- allocations within each pool. Idempotent: safe to run more than once. diff --git a/src/lib/db/migrations/085_api_key_allowed_quotas.sql b/src/lib/db/migrations/086_api_key_allowed_quotas.sql similarity index 100% rename from src/lib/db/migrations/085_api_key_allowed_quotas.sql rename to src/lib/db/migrations/086_api_key_allowed_quotas.sql diff --git a/src/lib/db/migrations/086_quota_pool_connections.sql b/src/lib/db/migrations/087_quota_pool_connections.sql similarity index 100% rename from src/lib/db/migrations/086_quota_pool_connections.sql rename to src/lib/db/migrations/087_quota_pool_connections.sql diff --git a/src/lib/db/migrations/087_quota_groups.sql b/src/lib/db/migrations/088_quota_groups.sql similarity index 100% rename from src/lib/db/migrations/087_quota_groups.sql rename to src/lib/db/migrations/088_quota_groups.sql diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 33e6a59c34..f8206565fd 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -355,7 +355,11 @@ export async function addCustomModel( | "audio-transcriptions" | "audio-speech" | "images-generations" = "chat-completions", - supportedEndpoints: string[] = ["chat"] + supportedEndpoints: string[] = ["chat"], + // #2905: optional per-model wire format override (e.g. "claude" for an + // opencode-go custom model). When unset, routing falls back to the provider + // default format. + targetFormat?: string ) { const db = getDbInstance(); const row = db @@ -373,6 +377,7 @@ export async function addCustomModel( source, apiFormat, supportedEndpoints, + ...(targetFormat ? { targetFormat } : {}), }; models.push(model); db.prepare( @@ -398,6 +403,7 @@ export async function replaceCustomModels( outputTokenLimit?: number; description?: string; supportsThinking?: boolean; + targetFormat?: string; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { @@ -427,6 +433,12 @@ export async function replaceCustomModels( source: m.source || "auto-sync", apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions", supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"], + // #2905: preserve a per-model targetFormat override (new value wins, else prev). + ...(m.targetFormat + ? { targetFormat: m.targetFormat } + : (prev as any)?.targetFormat + ? { targetFormat: (prev as any).targetFormat } + : {}), // Preserve metadata from provider API (or previous sync) ...(m.inputTokenLimit != null ? { inputTokenLimit: m.inputTokenLimit } @@ -815,6 +827,7 @@ export async function updateCustomModel( ...current, ...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}), ...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}), + ...(updates.targetFormat !== undefined ? { targetFormat: updates.targetFormat } : {}), ...(updates.supportedEndpoints !== undefined ? { supportedEndpoints: updates.supportedEndpoints } : {}), diff --git a/src/lib/db/notion.ts b/src/lib/db/notion.ts new file mode 100644 index 0000000000..2a12518204 --- /dev/null +++ b/src/lib/db/notion.ts @@ -0,0 +1,48 @@ +import { getDbInstance } from "./core"; + +const NOTION_NAMESPACE = "notion"; +const NOTION_TOKEN_KEY = "integration_token"; + +type KeyValueRow = { + value?: string; +}; + +export function getNotionToken(): string | null { + try { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(NOTION_NAMESPACE, NOTION_TOKEN_KEY) as KeyValueRow | undefined; + return typeof row?.value === "string" ? JSON.parse(row.value) : null; + } catch { + return null; + } +} + +export function setNotionToken(token: string): void { + try { + const db = getDbInstance(); + db.prepare( + "INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ).run(NOTION_NAMESPACE, NOTION_TOKEN_KEY, JSON.stringify(token)); + } catch { + // Non-fatal — token still works in-memory if persistence fails. + } +} + +export function clearNotionToken(): void { + try { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + NOTION_NAMESPACE, + NOTION_TOKEN_KEY + ); + } catch { + // Non-fatal. + } +} + +export function getNotionConfig(): { token: string | null; connected: boolean } { + const token = getNotionToken(); + return { token, connected: token !== null && token.length > 0 }; +} diff --git a/src/lib/freeProxyProviders/proxifly.ts b/src/lib/freeProxyProviders/proxifly.ts index 6895952118..00b4826367 100644 --- a/src/lib/freeProxyProviders/proxifly.ts +++ b/src/lib/freeProxyProviders/proxifly.ts @@ -3,16 +3,53 @@ import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; const DEFAULT_QUANTITY = 100; const DEFAULT_ANONYMITY = "elite"; +const DEFAULT_API_URL = "https://api.proxifly.dev/proxy"; +const DEFAULT_PROTOCOL = "http"; +const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_BATCH_QUANTITY = 20; type ProxiflyProxy = { - ip: string; - port: number; - protocol: string; - country: string; - anonymity: string; - speed: number; + ip?: string; + port?: number | string; + protocol?: string; + country?: string; + anonymity?: string; + speed?: number; + score?: number; + quality_score?: number; + geolocation?: { + country?: string | null; + } | null; }; +function parsePositiveInt(value: string | undefined, fallback: number): number { + const parsed = parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeProxyResponse(value: unknown): ProxiflyProxy[] { + if (Array.isArray(value)) return value as ProxiflyProxy[]; + if (value && typeof value === "object") { + const maybeWrapped = value as { proxies?: unknown }; + if (Array.isArray(maybeWrapped.proxies)) return maybeWrapped.proxies as ProxiflyProxy[]; + return [value as ProxiflyProxy]; + } + return []; +} + +function normalizeProxyType(protocol: string | undefined): FreeProxyItem["type"] { + const normalized = protocol?.toLowerCase(); + return normalized === "https" || normalized === "socks4" || normalized === "socks5" + ? normalized + : "http"; +} + +function normalizeQualityScore(proxy: ProxiflyProxy): number | null { + const raw = proxy.speed ?? proxy.quality_score ?? proxy.score; + if (raw == null || !Number.isFinite(raw)) return null; + return Math.min(100, Math.max(0, Math.round(raw))); +} + export class ProxiflyProvider implements FreeProxyProvider { readonly id = "proxifly" as const; readonly name = "Proxifly"; @@ -27,50 +64,65 @@ export class ProxiflyProvider implements FreeProxyProvider { } const { upsertFreeProxy } = await import("../db/freeProxies"); - const quantity = - parseInt(process.env.FREE_PROXY_PROXIFLY_QUANTITY || "", 10) || DEFAULT_QUANTITY; + const quantity = parsePositiveInt(process.env.FREE_PROXY_PROXIFLY_QUANTITY, DEFAULT_QUANTITY); const anonymity = process.env.FREE_PROXY_PROXIFLY_ANONYMITY || DEFAULT_ANONYMITY; const errors: string[] = []; let added = 0; let updated = 0; let fetched = 0; + let requested = 0; try { - const proxiflyModule = await import("proxifly"); - const proxifly = proxiflyModule.default ?? proxiflyModule; - const result = await (proxifly as { getProxy: (opts: unknown) => Promise }).getProxy( - { - protocol: "http", - anonymity: anonymity as "elite" | "anonymous" | "transparent", - speed: "fast", - quantity, - } - ); + while (requested < quantity) { + const batchQuantity = Math.min(MAX_BATCH_QUANTITY, quantity - requested); + const url = new URL(DEFAULT_API_URL); + url.searchParams.set("format", "json"); + url.searchParams.set("quantity", String(batchQuantity)); + url.searchParams.set("protocol", DEFAULT_PROTOCOL); + url.searchParams.set("anonymity", anonymity); - const proxies: ProxiflyProxy[] = Array.isArray(result) ? result : [result as ProxiflyProxy]; + const res = await fetch(url, { + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); - for (const p of proxies) { - if (!p.ip || !p.port) continue; - if (isPrivateHost(p.ip)) { - errors.push(`Proxifly: skipped private/loopback host ${p.ip}`); - continue; + if (!res.ok) { + const text = await res.text().catch(() => ""); + errors.push(`HTTP ${res.status}: ${text.slice(0, 100)}`); + break; } - const item: FreeProxyItem = { - source: "proxifly", - host: p.ip, - port: Number(p.port), - type: (p.protocol || "http").toLowerCase() as FreeProxyItem["type"], - countryCode: p.country?.slice(0, 2).toUpperCase() || null, - qualityScore: p.speed != null ? Math.min(100, Math.max(0, Math.round(p.speed))) : null, - latencyMs: null, - anonymity: p.anonymity || null, - lastValidated: new Date().toISOString(), - }; - const r = await upsertFreeProxy(item); - if (r.action === "created") added++; - else updated++; - fetched++; + + const proxies = normalizeProxyResponse(await res.json()); + if (proxies.length === 0) break; + requested += proxies.length; + + for (const p of proxies) { + if (!p || !p.ip || !p.port) continue; + if (isPrivateHost(p.ip)) { + errors.push(`Proxifly: skipped private/loopback host ${p.ip}`); + continue; + } + const item: FreeProxyItem = { + source: "proxifly", + host: p.ip, + port: Number(p.port), + type: normalizeProxyType(p.protocol), + countryCode: + p.geolocation?.country?.slice(0, 2).toUpperCase() || + p.country?.slice(0, 2).toUpperCase() || + null, + qualityScore: normalizeQualityScore(p), + latencyMs: null, + anonymity: p.anonymity || null, + lastValidated: new Date().toISOString(), + }; + const r = await upsertFreeProxy(item); + if (r.action === "created") added++; + else updated++; + fetched++; + } + + if (proxies.length < batchQuantity) break; } } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index b1fea0c111..94f092acc5 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -18,11 +18,14 @@ import { isVisionBridgeForcedModel, } from "@/shared/constants/visionBridgeDefaults"; -/// Check if a model with a combo mapping should trigger vision bridge processing. -/// Resolves the combo targets and only returns true if at least one target model -/// does NOT support vision natively. This avoids unnecessary vision model calls -/// when all combo targets can handle images directly. -async function shouldProcessImagesForComboModel(model: string): Promise { +type ComboVisionBridgeDecision = "process" | "skip" | "not-combo"; + +/// Check if a combo model should trigger vision bridge processing. +/// Resolves combo targets and returns: +/// - "process" if any target cannot be proven vision-capable +/// - "skip" if all model targets can handle images directly +/// - "not-combo" when the model is not a combo/mapping +async function getComboVisionBridgeDecision(model: string): Promise { try { const { getComboByName } = await import("@/lib/localDb"); const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); @@ -33,17 +36,17 @@ async function shouldProcessImagesForComboModel(model: string): Promise // 2. If no exact match, try model-combo mapping if (!combo) { const mapping = await resolveComboForModel(model); - if (!mapping) return false; + if (!mapping) return "not-combo"; const comboName = mapping.comboName ?? mapping.name ?? null; - if (!comboName) return false; + if (!comboName) return "not-combo"; combo = await getComboByName(comboName); } - if (!combo) return false; + if (!combo) return "not-combo"; // 3. Get the combo's models (target steps) const rawModels = (combo as Record).models; - if (!Array.isArray(rawModels)) return false; + if (!Array.isArray(rawModels)) return "process"; // 4. Check each target for vision support // combo-ref → conservative (process images) @@ -52,27 +55,29 @@ async function shouldProcessImagesForComboModel(model: string): Promise let hasModelStep = false; for (const step of rawModels) { const s = step as Record; - if (s.kind === "combo-ref") return true; + if (s.kind === "combo-ref") return "process"; if (s.kind === "model") { hasModelStep = true; const targetModel = s.model; if (typeof targetModel === "string") { const caps = getResolvedModelCapabilities(targetModel); if (caps.supportsVision !== true) { - return true; + return "process"; } + } else { + return "process"; } } } // All model steps support vision — safe to skip - if (hasModelStep) return false; + if (hasModelStep) return "skip"; // No recognizable steps — don't force bridge - return false; + return "not-combo"; } catch { // On error, try to process images (conservative) - return true; + return "process"; } } @@ -120,15 +125,24 @@ export class VisionBridgeGuardrail extends BaseGuardrail { // 4. Check if model supports vision const capabilities = getResolvedModelCapabilities(model); + const comboVisionBridgeDecision = forceVisionBridge + ? "process" + : this.deps.checkModelHasComboMapping + ? (await this.deps.checkModelHasComboMapping(model)) + ? "process" + : "skip" + : await getComboVisionBridgeDecision(model); + + if (comboVisionBridgeDecision === "skip") { + return { block: false }; + } + if (capabilities.supportsVision === true && !forceVisionBridge) { // The request model supports vision natively, but check if a // model-combo mapping routes this model through a combo where // some targets may NOT support vision. In that case, the vision // bridge must process images so combo targets can describe them. - const hasMapping = this.deps.checkModelHasComboMapping - ? await this.deps.checkModelHasComboMapping(model) - : await shouldProcessImagesForComboModel(model); - if (!hasMapping) { + if (comboVisionBridgeDecision !== "process") { return { block: false }; } // Combo mapping found — fall through to process images diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 8b362dde6a..3a2bd1d1b0 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -281,7 +281,11 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo lastUpdated: synced?.last_updated ?? null, modalitiesInput, modalitiesOutput, - interleavedField: synced?.interleaved_field ?? null, + interleavedField: + synced?.interleaved_field ?? + (typeof registryModel?.interleavedField === "string" + ? registryModel.interleavedField + : null), }; } diff --git a/src/lib/notion/api.ts b/src/lib/notion/api.ts new file mode 100644 index 0000000000..5e52b0cc2d --- /dev/null +++ b/src/lib/notion/api.ts @@ -0,0 +1,249 @@ +const NOTION_API_BASE = "https://api.notion.com/v1"; +const NOTION_VERSION = "2026-03-11"; +const MAX_RETRIES = 3; +const TIMEOUT_MS = 55000; + +export class NotionAuthError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionAuthError"; + } +} + +export class NotionNotFoundError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionNotFoundError"; + } +} + +export class NotionRateLimitError extends Error { + retryAfter: number; + constructor(msg: string, retryAfter: number) { + super(msg); + this.name = "NotionRateLimitError"; + this.retryAfter = retryAfter; + } +} + +export class NotionValidationError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionValidationError"; + } +} + +export class NotionServerError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionServerError"; + } +} + +export class NotionTimeoutError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionTimeoutError"; + } +} + +type NotionErrorBody = { + object: "error"; + status: number; + code: string; + message: string; +}; + +function classifyNotionError(status: number, code: string, message: string): Error { + switch (status) { + case 401: + return new NotionAuthError(message); + case 403: + return new NotionAuthError(`Access denied: ${message}`); + case 404: + return new NotionNotFoundError(message); + case 409: + return new NotionValidationError(`Conflict: ${message}`); + case 429: { + const retryAfter = 1; + const match = message.match(/retry after (\d+)/i) ?? message.match(/(\d+)/); + const parsed = match ? parseInt(match[1], 10) : 1; + return new NotionRateLimitError(message, Math.max(parsed, retryAfter)); + } + case 400: + return new NotionValidationError(message); + default: + if (status >= 500) return new NotionServerError(message); + return new NotionValidationError(message); + } +} + +function sanitize(msg: string): string { + return msg.replace(/\s+at\s+\S+/g, "").replace(/\/[\w/.-]+\.[a-z]+\:\d+/g, "").slice(0, 4096); +} + +async function notionFetch( + path: string, + apiKey: string, + options: RequestInit = {} +): Promise { + const url = `${NOTION_API_BASE}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const mergedSignal = options.signal + ? combineSignals(options.signal, controller.signal) + : controller.signal; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + ...(options.headers as Record), + }, + signal: mergedSignal, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})) as Record; + const errBody = body as Partial; + const code = errBody?.code ?? "unknown"; + const msg = errBody?.message ?? `HTTP ${response.status}`; + const error = classifyNotionError(response.status, code, msg); + + if (error instanceof NotionRateLimitError) { + lastError = error; + const waitMs = error.retryAfter * 1000 + Math.pow(2, attempt) * 200; + await sleep(waitMs); + continue; + } + + if (error instanceof NotionServerError && attempt < MAX_RETRIES - 1) { + lastError = error; + await sleep(Math.pow(2, attempt) * 500); + continue; + } + + throw error; + } + + return response.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + clearTimeout(timeout); + throw new NotionTimeoutError("Notion API request timed out after 55s"); + } + if (err instanceof NotionAuthError || err instanceof NotionNotFoundError || err instanceof NotionValidationError) { + clearTimeout(timeout); + throw err; + } + if (attempt < MAX_RETRIES - 1) { + lastError = err instanceof Error ? err : new NotionServerError(String(err)); + await sleep(Math.pow(2, attempt) * 500); + continue; + } + } + } + + clearTimeout(timeout); + throw lastError ?? new NotionServerError("Exhausted all retries"); +} + +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createNotionClient(apiKey: string) { + const client = { + async searchPagesAndDatabases( + query: string, + startCursor?: string, + pageSize = 20 + ): Promise { + const body: Record = { + query, + page_size: Math.min(pageSize, 100), + filter: { value: "page", property: "object" }, + }; + if (startCursor) body.start_cursor = startCursor; + return notionFetch("/search", apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getPage(pageId: string): Promise { + return notionFetch(`/pages/${pageId}`, apiKey); + }, + + async listBlockChildren( + blockId: string, + startCursor?: string, + pageSize = 50 + ): Promise { + const params = new URLSearchParams(); + params.set("page_size", String(Math.min(pageSize, 100))); + if (startCursor) params.set("start_cursor", startCursor); + return notionFetch(`/blocks/${blockId}/children?${params}`, apiKey); + }, + + async queryDatabase( + databaseId: string, + filter?: unknown, + sorts?: unknown[], + startCursor?: string, + pageSize = 50 + ): Promise { + const body: Record = { + page_size: Math.min(pageSize, 100), + }; + if (filter) body.filter = filter; + if (sorts) body.sorts = sorts; + if (startCursor) body.start_cursor = startCursor; + return notionFetch(`/databases/${databaseId}/query`, apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getDatabase(databaseId: string): Promise { + return notionFetch(`/databases/${databaseId}`, apiKey); + }, + + async appendBlocks( + blockId: string, + children: unknown[], + after?: string + ): Promise { + const body: Record = { + children: children.slice(0, 100), + }; + if (after) body.after = after; + return notionFetch(`/blocks/${blockId}/children`, apiKey, { + method: "PATCH", + body: JSON.stringify(body), + }); + }, + }; + + return client; +} + +export type NotionClient = ReturnType; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 9d4f29c731..ea10329cc6 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -348,9 +348,19 @@ export const TRAE_CONFIG = { chatEndpoint: "/v1/chat/completions", // Trae website — users retrieve their token here after signing in webUrl: "https://trae.ai", - // Token storage note for users — no automated extraction path is available - // because Trae does not expose a public SQLite / keychain location yet. - tokenNote: "Sign in to Trae IDE, then copy your API token from the account settings.", + // SOLO remote agent base — the executor's real upstream. Also set as the + // provider registry baseUrl, which is the source of truth at request time. + soloApiEndpoint: "https://core-normal.trae.ai/api/remote/v1", + // SOLO model catalogue endpoint (relative to soloApiEndpoint). + modelsEndpoint: "/models?functions=solo_agent_remote,solo_work_remote", + // Authorization scheme: `Authorization: Cloud-IDE-JWT ` (RS256). + authScheme: "Cloud-IDE-JWT", + // Observed Cloud-IDE-JWT lifetime — drives default expiry hints. + tokenLifetimeDays: 14, + // Token storage note — solo.trae.ai exposes no public SQLite/keychain path, + // so the token is captured via the /authorize flow or pasted manually. + tokenNote: + "Authorize via trae.ai in the popup, or sign in to solo.trae.ai and paste the Cloud-IDE-JWT from the Authorization header (~14-day lifetime).", }; // Windsurf / Devin CLI Configuration diff --git a/src/lib/oauth/providers/trae.ts b/src/lib/oauth/providers/trae.ts index ddc45134c4..d60081e5c9 100644 --- a/src/lib/oauth/providers/trae.ts +++ b/src/lib/oauth/providers/trae.ts @@ -1,31 +1,76 @@ import { TRAE_CONFIG } from "../constants/oauth"; /** - * Trae IDE OAuth Provider (Import Token) + * Trae SOLO OAuth provider — token-import flow (no public OAuth client). * - * Trae is an AI-native IDE by ByteDance. Authentication relies on a personal - * API token that the user copies from the Trae account settings page and pastes - * into the OmniRoute connection form. + * ByteDance has not published a public OAuth client_id/secret or a device-code + * flow for third-party integrations, so the credential is the Cloud-IDE-JWT + * captured either via the browser /authorize popup (src/app/authorize/route.ts) + * or by signing in to solo.trae.ai and pasting the token sent in the + * `Authorization: Cloud-IDE-JWT ` header (~14-day lifetime). * - * Why import_token and not device_code / authorization_code: - * ByteDance has not published a public OAuth client_id/secret for the Trae - * IDE, nor documented a device-code or browser-redirect flow for third-party - * integrations. The authHint in providers.ts (see IDE_PROVIDER_IDS) confirms - * that "paste your API token" is the supported onboarding path. + * mapTokens enriches providerSpecificData with the identity fields the SOLO + * remote agent requires inside its `common_params` payload (web_id / + * biz_user_id / user_unique_id / scope / tenant / region). The dedicated import + * route and /authorize callback also build this record directly; mapTokens keeps + * the device-code / exchange code paths consistent if Trae ever exposes one. * * TODO(trae-auth): if ByteDance publishes a public OAuth application for Trae, * upgrade flowType to "device_code" or "authorization_code_pkce" and embed * the client credentials via resolvePublicCred() (Hard Rule #11). - * Reference: https://docs.trae.ai (check for OAuth / CLI integration docs) */ +type TraeRawTokens = { + accessToken?: string; + access_token?: string; + expiresIn?: number; + refreshToken?: string | null; + machineId?: string; + webId?: string; + web_id?: string; + bizUserId?: string; + biz_user_id?: string; + userUniqueId?: string; + user_unique_id?: string; + scope?: string; + tenant?: string; + region?: string; + aiRegion?: string; + ai_region?: string; + appLanguage?: string; + app_language?: string; + appVersion?: string; + app_version?: string; + userRegion?: string; + user_region?: string; + userIdentity?: string; + user_identity?: string; +}; + export const trae = { config: TRAE_CONFIG, flowType: "import_token", - mapTokens: (tokens: { accessToken: string; expiresIn?: number; machineId?: string }) => ({ - accessToken: tokens.accessToken, - refreshToken: null, - expiresIn: tokens.expiresIn || 86400, + mapTokens: (tokens: TraeRawTokens) => ({ + accessToken: tokens.accessToken || tokens.access_token, + // Pasted/solo JWTs have no refresh token; the /authorize callback persists + // its own RefreshToken directly (see parseCallback.ts) rather than via here. + refreshToken: tokens.refreshToken ?? null, + // Default to the observed ~14-day Cloud-IDE-JWT lifetime when the caller did + // not supply an explicit expiry, so connection cooldown / expiry hints behave + // sensibly out of the box (TRAE_CONFIG.tokenLifetimeDays). + expiresIn: tokens.expiresIn || TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, providerSpecificData: { + webId: tokens.webId || tokens.web_id || "", + bizUserId: tokens.bizUserId || tokens.biz_user_id || "", + userUniqueId: tokens.userUniqueId || tokens.user_unique_id || "", + scope: tokens.scope || "marscode-us", + tenant: tokens.tenant || "marscode", + region: tokens.region || "US-East", + aiRegion: tokens.aiRegion || tokens.ai_region || tokens.region || "US-East", + appLanguage: tokens.appLanguage || tokens.app_language || "en", + appVersion: tokens.appVersion || tokens.app_version || "1.0.0.1229", + userRegion: tokens.userRegion || tokens.user_region || "US", + userIdentity: tokens.userIdentity || tokens.user_identity || "Free", + // Preserved for callers that key off a machine id (e.g. the IDE flow). machineId: tokens.machineId, authMethod: "imported", }, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 269528860c..19976bd5bd 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -382,10 +382,21 @@ async function validateOpenAILikeProvider({ return { valid: true, error: null }; } - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { return { valid: false, error: "Invalid API key" }; } + // #2929: A 403 on the models endpoint is not always a bad key. Some providers + // (e.g. Fireworks Fire Pass `fpk_*` keys) return "...not authorized for this + // route." on /models while still serving chat. Fall through to the chat probe + // for such route-restriction 403s instead of declaring the key invalid. + if (response.status === 403) { + const forbiddenBody = await response.text().catch(() => ""); + if (!/not authorized for this route/i.test(forbiddenBody)) { + return { valid: false, error: "Invalid API key" }; + } + } + const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData); if (!chatUrl) { return { valid: false, error: `Validation failed: ${response.status}` }; diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index f26b954109..f026e3a6ab 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -290,18 +290,52 @@ export async function handleToolCallExecution( }; } - case "anthropic": + case "anthropic": { + // Anthropic only permits tool_result blocks in user messages. This helper + // returns a single assistant response, so there is no valid place to put a + // server-side skill result as tool_result here. Keep client-native tool_use + // blocks untouched, remove the OmniRoute-handled tool_use blocks, and expose + // their results as plain assistant text instead of corrupting history with + // assistant-side tool_result blocks. See #2815. + // + // When no client-native tool_use blocks remain (all were handled here), the + // upstream stop_reason "tool_use" is stale and would make clients wait for + // tool_use blocks that no longer exist — so it is normalized to "end_turn" + // (with stop_sequence cleared). The mixed-tool branch keeps the original + // stop_reason because real native tool_use blocks still need the client. + const handledToolCallIds = new Set(results.map((r) => r.id)); + const toolNamesById = new Map(toolCalls.map((call) => [call.id, call.name])); + const remainingContent = (Array.isArray(response.content) ? response.content : []).filter( + (block: any) => !(block?.type === "tool_use" && handledToolCallIds.has(block.id)) + ); + const resultTextBlocks = results.map((r) => ({ + type: "text", + text: `[Skill result: ${toolNamesById.get(r.id) || r.id}]\n${JSON.stringify( + r.result + )}`, + })); + const firstRemainingToolUseIndex = remainingContent.findIndex( + (block: any) => block?.type === "tool_use" + ); + + if (firstRemainingToolUseIndex === -1) { + return { + ...response, + content: [...remainingContent, ...resultTextBlocks], + stop_reason: "end_turn", + stop_sequence: null, + }; + } + return { ...response, content: [ - ...response.content, - ...results.map((r) => ({ - type: "tool_result", - tool_use_id: r.id, - content: JSON.stringify(r.result), - })), + ...remainingContent.slice(0, firstRemainingToolUseIndex), + ...resultTextBlocks, + ...remainingContent.slice(firstRemainingToolUseIndex), ], }; + } default: return response; diff --git a/src/lib/usage/apiKeySelfService.ts b/src/lib/usage/apiKeySelfService.ts index f5c5ee422e..1e7237e12c 100644 --- a/src/lib/usage/apiKeySelfService.ts +++ b/src/lib/usage/apiKeySelfService.ts @@ -2,8 +2,10 @@ import { hasSelfAccountQuotaScope, hasSelfUsageScope, } from "@/shared/constants/selfServiceScopes"; +import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; type JsonRecord = Record; +type DateLike = number | string | Date | null | undefined; interface ApiKeySelfServiceMetadata { id: string; @@ -26,9 +28,9 @@ interface CostSummaryLike { totalCostPeriod: number; activeLimitUsd: number; resetInterval: string | null; - budgetResetAt: number | null; - periodStartAt: number | null; - nextResetAt: number | null; + budgetResetAt: DateLike; + periodStartAt: DateLike; + nextResetAt: DateLike; warningThreshold: number | null; } @@ -36,6 +38,7 @@ type GetCostSummaryFn = (apiKeyId: string) => CostSummaryLike; type CheckBudgetFn = (apiKeyId: string) => unknown; type GetDbInstanceFn = () => DbLike; type GetProviderConnectionByIdFn = (connectionId: string) => Promise; +type GetProviderConnectionsFn = (filters?: Record) => Promise; type FetchAndPersistProviderLimitsFn = ( connectionId: string, source: "manual" @@ -47,6 +50,7 @@ interface ApiKeySelfServiceDeps { checkBudget?: CheckBudgetFn; getDbInstance?: GetDbInstanceFn; getProviderConnectionById?: GetProviderConnectionByIdFn; + getProviderConnections?: GetProviderConnectionsFn; fetchAndPersistProviderLimits?: FetchAndPersistProviderLimitsFn; } @@ -59,6 +63,12 @@ interface TokenTotals { totalTokens: number; } +interface AccountQuotaConnection { + id: string; + provider: string; + lookupFailed?: boolean; +} + function toNumber(value: unknown, fallback = 0): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "bigint") return Number(value); @@ -74,17 +84,30 @@ function roundNumber(value: number, precision = 6): number { return Number(value.toFixed(precision)); } -function isoOrNull(value: number | string | null | undefined): string | null { +function dateMsOrNull(value: DateLike): number | null { if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return new Date(value).toISOString(); + return value; + } + if (value instanceof Date) { + const parsed = value.getTime(); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } if (typeof value === "string" && value.trim()) { const parsed = Date.parse(value); - return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } return null; } +function isoOrNull(value: DateLike): string | null { + const timestamp = dateMsOrNull(value); + return timestamp === null ? null : new Date(timestamp).toISOString(); +} + +function withDateFallback(value: DateLike, fallback: number): DateLike { + return isoOrNull(value) === null ? fallback : value; +} + function getCurrentMonthWindow(now: number) { const date = new Date(now); const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0); @@ -96,10 +119,10 @@ function buildCostStatus(summary: CostSummaryLike, now: number) { const hasBudget = !!summary.budget && toNumber(summary.activeLimitUsd) > 0; const fallbackWindow = getCurrentMonthWindow(now); const periodStartAt = hasBudget - ? toNumber(summary.periodStartAt, fallbackWindow.periodStartAt) + ? withDateFallback(summary.periodStartAt, fallbackWindow.periodStartAt) : fallbackWindow.periodStartAt; const resetAt = hasBudget - ? toNumber(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt) + ? withDateFallback(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt) : fallbackWindow.resetAt; const usedUsd = hasBudget ? roundNumber(toNumber(summary.totalCostPeriod)) @@ -177,66 +200,170 @@ function quotaWindow(value: unknown) { remainingPercentage: Number.isFinite(remainingPercentage) ? roundNumber(remainingPercentage, 2) : roundNumber(100 - usedPercentage, 2), - resetAt: isoOrNull(record.resetAt as string | number | null | undefined), + resetAt: isoOrNull(record.resetAt as DateLike), }; } -async function resolveAccountQuota(metadata: ApiKeySelfServiceMetadata, deps: RequiredDeps) { - if (!hasSelfAccountQuotaScope(metadata.scopes)) return undefined; +function normalizePlan(value: unknown): unknown { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean") return value; + return undefined; +} +function isSupportedProvider(provider: string): boolean { + return USAGE_SUPPORTED_PROVIDERS.includes(provider as (typeof USAGE_SUPPORTED_PROVIDERS)[number]); +} + +function getConnectionIdentity(value: unknown): { id: string; provider: string } | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as JsonRecord; + if (record.isActive === false) return null; + + const id = typeof record.id === "string" ? record.id : ""; + const provider = typeof record.provider === "string" ? record.provider : ""; + if (!id || !provider) return null; + + return { id, provider }; +} + +async function listAccountQuotaConnections( + metadata: ApiKeySelfServiceMetadata, + deps: RequiredDeps +) { const allowedConnections = Array.isArray(metadata.allowedConnections) ? metadata.allowedConnections : []; - if (allowedConnections.length !== 1) { - return unavailableAccountQuota("ambiguous_connection"); + + const rawConnections = + allowedConnections.length > 0 + ? await Promise.all( + allowedConnections.map(async (id) => { + try { + return await deps.getProviderConnectionById(id); + } catch { + return { id, provider: "unknown", lookupFailed: true }; + } + }) + ) + : await deps.getProviderConnections({ isActive: true }).catch(() => []); + + const connections: AccountQuotaConnection[] = []; + const seen = new Set(); + for (const rawConnection of rawConnections) { + if ( + rawConnection && + typeof rawConnection === "object" && + !Array.isArray(rawConnection) && + (rawConnection as JsonRecord).lookupFailed === true + ) { + const record = rawConnection as JsonRecord; + const id = typeof record.id === "string" ? record.id : ""; + const provider = typeof record.provider === "string" ? record.provider : "unknown"; + if (!id || seen.has(id)) continue; + seen.add(id); + connections.push({ id, provider, lookupFailed: true }); + continue; + } + + const connection = getConnectionIdentity(rawConnection); + if (!connection || seen.has(connection.id)) continue; + seen.add(connection.id); + connections.push(connection); } - const connection = (await deps.getProviderConnectionById(allowedConnections[0])) as - | JsonRecord - | null; - if (!connection) { - return unavailableAccountQuota("no_allowed_connection"); + return connections; +} + +function normalizeQuotaWindows(quotas: JsonRecord | null) { + if (!quotas) return null; + + const normalized: Record> = {}; + for (const [key, value] of Object.entries(quotas)) { + const window = quotaWindow(value); + if (window) normalized[key] = window; } - const provider = typeof connection.provider === "string" ? connection.provider : ""; - if (provider !== "codex") { - return unavailableAccountQuota("not_supported"); + return Object.keys(normalized).length > 0 ? normalized : null; +} + +async function resolveConnectionAccountQuota( + connection: AccountQuotaConnection, + deps: RequiredDeps +) { + if (connection.lookupFailed) { + return { + provider: connection.provider, + connectionId: connection.id, + shared: true, + ...unavailableAccountQuota("connection_lookup_failed"), + }; + } + + if (!isSupportedProvider(connection.provider)) { + return { + provider: connection.provider, + connectionId: connection.id, + shared: true, + ...unavailableAccountQuota("not_supported"), + }; } try { - const result = await deps.fetchAndPersistProviderLimits(allowedConnections[0], "manual"); + const result = await deps.fetchAndPersistProviderLimits(connection.id, "manual"); const usage = result.usage as JsonRecord; const quotas = usage.quotas && typeof usage.quotas === "object" && !Array.isArray(usage.quotas) ? (usage.quotas as JsonRecord) : null; - if (!quotas) return unavailableAccountQuota("not_available"); + const normalizedQuotas = normalizeQuotaWindows(quotas); + const plan = normalizePlan(usage.plan); - const session = quotaWindow(quotas.session); - const weekly = quotaWindow(quotas.weekly); - if (!session && !weekly) return unavailableAccountQuota("not_available"); + if (!normalizedQuotas && plan === undefined) { + return { + provider: connection.provider, + connectionId: connection.id, + shared: true, + ...unavailableAccountQuota("not_available"), + }; + } return { - provider, - connectionId: allowedConnections[0], + provider: connection.provider, + connectionId: connection.id, shared: true, - quotas: { - ...(session && { session }), - ...(weekly && { weekly }), - }, + ...(plan !== undefined && { plan }), + ...(normalizedQuotas && { quotas: normalizedQuotas }), }; } catch { - return unavailableAccountQuota("fetch_failed"); + return { + provider: connection.provider, + connectionId: connection.id, + shared: true, + ...unavailableAccountQuota("fetch_failed"), + }; } } +async function resolveAccountQuotas(metadata: ApiKeySelfServiceMetadata, deps: RequiredDeps) { + if (!hasSelfAccountQuotaScope(metadata.scopes)) return undefined; + + const connections = await listAccountQuotaConnections(metadata, deps); + return Promise.all( + connections.map((connection) => resolveConnectionAccountQuota(connection, deps)) + ); +} + type RequiredDeps = Required; async function normalizeDeps(deps: ApiKeySelfServiceDeps): Promise { const costRules = deps.getCostSummary && deps.checkBudget ? null : await import("@/domain/costRules"); const dbCore = deps.getDbInstance ? null : await import("@/lib/db/core"); - const localDb = deps.getProviderConnectionById ? null : await import("@/lib/localDb"); + const localDb = + deps.getProviderConnectionById && deps.getProviderConnections + ? null + : await import("@/lib/localDb"); const providerLimits = deps.fetchAndPersistProviderLimits ? null : await import("@/lib/usage/providerLimits"); @@ -247,6 +374,7 @@ async function normalizeDeps(deps: ApiKeySelfServiceDeps): Promise checkBudget: deps.checkBudget ?? costRules!.checkBudget, getDbInstance: deps.getDbInstance ?? dbCore!.getDbInstance, getProviderConnectionById: deps.getProviderConnectionById ?? localDb!.getProviderConnectionById, + getProviderConnections: deps.getProviderConnections ?? localDb!.getProviderConnections, fetchAndPersistProviderLimits: deps.fetchAndPersistProviderLimits ?? providerLimits!.fetchAndPersistProviderLimits, }; @@ -270,7 +398,9 @@ export async function buildApiKeySelfServiceStatus( metadata.id, cost.periodStartAt ?? new Date(getCurrentMonthWindow(resolvedDeps.now()).periodStartAt).toISOString() ); - const accountQuota = await resolveAccountQuota(metadata, resolvedDeps); + const accountQuotas = await resolveAccountQuotas(metadata, resolvedDeps); + const accountQuota = + accountQuotas && accountQuotas.length === 1 ? accountQuotas[0] : undefined; return { apiKey: { @@ -284,6 +414,7 @@ export async function buildApiKeySelfServiceStatus( ...tokens, }, }, + ...(accountQuotas !== undefined && { accountQuotas }), ...(accountQuota !== undefined && { accountQuota }), }; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index ac12edb201..575e8acb50 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -113,6 +113,7 @@ async function syncToCloudIfEnabled() { async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) { const executor = getExecutor(connection.provider); const credentials = { + connectionId: connection.id, accessToken: connection.accessToken, refreshToken: connection.refreshToken, expiresAt: connection.tokenExpiresAt || connection.expiresAt || null, diff --git a/src/shared/components/ActiveRequestsPanel.tsx b/src/shared/components/ActiveRequestsPanel.tsx index 2b58605b59..2466f26ac3 100644 --- a/src/shared/components/ActiveRequestsPanel.tsx +++ b/src/shared/components/ActiveRequestsPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { maskAccount } from "@/shared/utils/formatting"; +import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; type ActiveRequestRow = { @@ -46,6 +47,16 @@ export default function ActiveRequestsPanel() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [selectedRow, setSelectedRow] = useState(null); + const [providerNodes, setProviderNodes] = useState< + Array<{ id?: string; prefix?: string; name?: string }> + >([]); + + useEffect(() => { + fetch("/api/provider-nodes") + .then((r) => (r.ok ? r.json() : { nodes: [] })) + .then((d) => setProviderNodes(d.nodes || [])) + .catch(() => {}); + }, []); useEffect(() => { let cancelled = false; @@ -149,7 +160,9 @@ export default function ActiveRequestsPanel() { className="border-t border-border/60" > {row.model} - {row.provider} + + {getProviderDisplayLabel(row.provider, providerNodes) || row.provider} + {accountLabel} @@ -178,7 +191,9 @@ export default function ActiveRequestsPanel() {

- {selectedRow.provider} / {selectedRow.model} + {getProviderDisplayLabel(selectedRow.provider, providerNodes) || + selectedRow.provider}{" "} + / {selectedRow.model}

{t("runningRequestDetailMeta", { diff --git a/src/shared/components/ProxyLogger.tsx b/src/shared/components/ProxyLogger.tsx index ba8fbcb23b..58375598ba 100644 --- a/src/shared/components/ProxyLogger.tsx +++ b/src/shared/components/ProxyLogger.tsx @@ -15,6 +15,7 @@ import { formatDuration as formatLatency, truncateUrl, } from "@/shared/utils/formatting"; +import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; const PROXY_COLUMN_KEYS = [ "status", @@ -43,6 +44,9 @@ export default function ProxyLogger() { const [selectedLevel, setSelectedLevel] = useState(""); const [sortBy, setSortBy] = useState("newest"); const [selectedLog, setSelectedLog] = useState(null); + const [providerNodes, setProviderNodes] = useState< + Array<{ id?: string; prefix?: string; name?: string }> + >([]); const intervalRef = useRef(null); const hasLoadedRef = useRef(false); const logsSignatureRef = useRef(""); @@ -132,6 +136,14 @@ export default function ProxyLogger() { fetchLogs(showLoading); }, [fetchLogs]); + // Fetch provider nodes for display labels + useEffect(() => { + fetch("/api/provider-nodes") + .then((r) => (r.ok ? r.json() : { nodes: [] })) + .then((d) => setProviderNodes(d.nodes || [])) + .catch(() => {}); + }, []); + useEffect(() => { if (intervalRef.current) clearInterval(intervalRef.current); if (recording) { @@ -454,10 +466,13 @@ export default function ProxyLogger() { label: log.proxy?.type || "-", }; const levelColor = LEVEL_COLORS[log.level] || LEVEL_COLORS.direct; + const resolvedProviderLabel = + getProviderDisplayLabel(log.provider, providerNodes) || + (log.provider || "-").toUpperCase(); const providerColor = PROVIDER_COLORS[log.provider] || { bg: "#374151", text: "#fff", - label: (log.provider || "-").toUpperCase(), + label: resolvedProviderLabel, }; const isError = log.status === "error" || log.status === "timeout"; diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 6fcf6bbb3f..f2cc14a1b4 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -18,6 +18,7 @@ import { stableAccountSuffix, formatApiKeyLabel, } from "@/shared/utils/formatting"; +import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; // Number of call-log rows fetched per page. The viewer grows its window by this @@ -25,39 +26,6 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore"; // page (previously hardcoded to a single 300-row window). See #2565. const PAGE_SIZE = 300; -/** - * Get a friendly display label for compatible providers. - * Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441" - * to readable labels. If providerNodes are available, uses user-defined name; - * otherwise falls back to "OAI-Compat". - */ -function getProviderDisplayLabel(provider: string, providerNodes?: any[]): string { - if (!provider) return "-"; - if (provider.startsWith("openai-compatible-") || provider.startsWith("anthropic-compatible-")) { - // Try to find user-defined name from provider nodes - if (providerNodes?.length) { - const matchedNode = providerNodes.find( - (node) => node.id === provider || node.prefix === provider - ); - if (matchedNode?.name) return matchedNode.name; - } - // Fallback to generic labels - if (provider.startsWith("openai-compatible-")) { - const suffix = provider.replace("openai-compatible-", ""); - const parts = suffix.split("-"); - if (parts.length > 1 && parts[1]?.length >= 8) return `OAI-COMPAT`; - return `OAI: ${suffix.slice(0, 16).toUpperCase()}`; - } - if (provider.startsWith("anthropic-compatible-")) { - const suffix = provider.replace("anthropic-compatible-", ""); - const parts = suffix.split("-"); - if (parts.length > 1 && parts[1]?.length >= 8) return `ANT-COMPAT`; - return `ANT: ${suffix.slice(0, 16).toUpperCase()}`; - } - } - return null; // Not a compatible provider, use default PROVIDER_COLORS -} - function getLogTotalTokens(log) { return (log?.tokens?.in || 0) + (log?.tokens?.out || 0); } diff --git a/src/shared/components/TraeAuthModal.tsx b/src/shared/components/TraeAuthModal.tsx new file mode 100644 index 0000000000..94d1508787 --- /dev/null +++ b/src/shared/components/TraeAuthModal.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import Input from "./Input"; + +const TRAE_CLIENT_ID = "en1oxy7wnw8j9n"; + +function uuid(): string { + const c = (globalThis.crypto || (globalThis as any).crypto) as Crypto | undefined; + if (c?.randomUUID) return c.randomUUID(); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +function randomHex(bytes: number): string { + const buf = new Uint8Array(bytes); + (globalThis.crypto || (globalThis as any).crypto).getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function randomDigits(n: number): string { + let s = ""; + while (s.length < n) s += Math.floor(Math.random() * 1e10).toString(); + return s.slice(0, n); +} + +function buildTraeAuthorizeUrl(callbackUrl: string, traceId: string): string { + // Per-attempt machine/device IDs — Trae doesn't tie tokens to a specific + // machine_id, but the field is required and is echoed into provider metadata. + const machineId = randomHex(32); + const deviceId = randomDigits(19); + const params = new URLSearchParams({ + login_version: "1", + auth_from: "solo", + login_channel: "native_ide", + plugin_version: "2.3.24254", + auth_type: "local", + client_id: TRAE_CLIENT_ID, + redirect: "0", + login_trace_id: traceId, + auth_callback_url: callbackUrl, + machine_id: machineId, + device_id: deviceId, + x_device_id: deviceId, + x_machine_id: machineId, + x_device_brand: "Mac14,7", + x_device_type: "mac", + x_os_version: "macOS 26.4.1", + x_env: "", + x_app_version: "0.1.7", + x_app_type: "stable", + // Match what the SOLO desktop client sends: hide_saas_login=true. With + // auth_from=solo, trae.ai's authorize page is *only* a confirmation gate + // for an already-cookied session — so leaving the SaaS sign-in surface + // visible (false) causes the server to short-circuit to "Login Failed". + // The user must be signed in on trae.ai in the same browser; we surface + // that in the modal copy and provide an "Open solo.trae.ai" button. + hide_saas_login: "true", + }); + return `https://www.trae.ai/authorization?${params.toString()}`; +} + +type TraeAuthModalProps = { + isOpen: boolean; + onSuccess?: () => void; + onClose: () => void; + reauthConnection?: unknown; +}; + +/** + * Trae SOLO Auth Modal — paste the Cloud-IDE-JWT from solo.trae.ai. + * + * Trae has no public OAuth or local credential store like Cursor: the user + * signs in to solo.trae.ai in a browser, copies the JWT sent in the + * Authorization header (Cloud-IDE-JWT scheme), and pastes it here. JWT + * lifetime is ~14 days; re-import on expiry. + */ +export default function TraeAuthModal({ + isOpen, + onSuccess, + onClose, + reauthConnection: _, +}: TraeAuthModalProps) { + const [accessToken, setAccessToken] = useState(""); + const [webId, setWebId] = useState(""); + const [bizUserId, setBizUserId] = useState(""); + const [userUniqueId, setUserUniqueId] = useState(""); + const [scope, setScope] = useState("marscode-us"); + const [tenant, setTenant] = useState("marscode"); + const [region, setRegion] = useState("US-East"); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [authorizing, setAuthorizing] = useState(false); + const popupRef = useRef(null); + const traceIdRef = useRef(null); + + // Listen for postMessage from the callback page (window.opener.postMessage). + // Only act on messages with our type tag, and verify the loginTraceId matches + // the one we sent in the authorize URL — protects against unrelated postMessages. + useEffect(() => { + if (!isOpen) return; + const onMessage = (ev: MessageEvent) => { + const m = ev.data as { + type?: string; + success?: boolean; + error?: string; + loginTraceId?: string; + } | null; + if (!m || m.type !== "trae-oauth-callback") return; + // Accept this window's origin OR the sibling loopback host: the dashboard + // usually runs on localhost while Trae forces the callback onto 127.0.0.1, + // so they are different origins by design. Restrict to that known pair + // (never wildcard), then rely on the random loginTraceId for CSRF. + const here = window.location; + const altHost = + here.hostname === "127.0.0.1" + ? "localhost" + : here.hostname === "localhost" + ? "127.0.0.1" + : null; + const allowedOrigins = new Set([here.origin]); + if (altHost) + allowedOrigins.add(`${here.protocol}//${altHost}${here.port ? `:${here.port}` : ""}`); + if (!allowedOrigins.has(ev.origin)) return; + if (!traceIdRef.current || m.loginTraceId !== traceIdRef.current) return; + setAuthorizing(false); + if (m.success) { + onSuccess?.(); + onClose(); + } else { + setError(m.error || "Authorization failed"); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [isOpen, onSuccess, onClose]); + + const handleAuthorizeWithBrowser = () => { + setError(null); + setAuthorizing(true); + const traceId = uuid(); + traceIdRef.current = traceId; + // Trae's authorize endpoint validates two things about auth_callback_url: + // 1. host must be a loopback IP (127.0.0.1) — "localhost" hostname gets + // rejected with "Login Failed". + // 2. path must end with `/authorize` — any other path (e.g. our earlier + // "/api/oauth/trae/callback") also short-circuits to "Login Failed". + // The receiving handler therefore lives at the app root (src/app/authorize). + const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); + const callbackUrl = `http://127.0.0.1:${port}/authorize`; + const authUrl = buildTraeAuthorizeUrl(callbackUrl, traceId); + const w = window.open(authUrl, "trae-oauth", "width=520,height=720"); + if (!w) { + setAuthorizing(false); + setError("Popup blocked — allow popups for this site, or paste the token manually below."); + return; + } + popupRef.current = w; + // If the user closes the popup without completing, drop the spinner. + const poll = setInterval(() => { + if (w.closed) { + clearInterval(poll); + setAuthorizing((prev) => { + if (prev) setError("Authorization window was closed before completing."); + return false; + }); + } + }, 700); + }; + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Access token is required."); + return; + } + setImporting(true); + setError(null); + try { + const body: Record = { accessToken: accessToken.trim() }; + if (webId.trim()) body.webId = webId.trim(); + if (bizUserId.trim()) body.bizUserId = bizUserId.trim(); + if (userUniqueId.trim()) body.userUniqueId = userUniqueId.trim(); + if (scope.trim()) body.scope = scope.trim(); + if (tenant.trim()) body.tenant = tenant.trim(); + if (region.trim()) body.region = region.trim(); + + const res = await fetch("/api/oauth/trae/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : data.error?.message || "Import failed" + ); + } + onSuccess?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + }; + + return ( + +

+ {/* Primary path: browser-based OAuth via trae.ai/authorization */} +
+

+ Authorize via trae.ai in a popup. The popup will + close itself once the token has been imported. +

+

+ Important: you must be logged in to{" "} + trae.ai in this browser first. The authorize + page only confirms an existing session — it cannot sign you in. If you see "Login + Failed", click "Open solo.trae.ai", sign in, then return here. +

+
+ + +
+
+ +
+ + or paste a token manually + +
+ +
+

+ Sign in to solo.trae.ai, open DevTools → Network, + send any chat message, and copy the JWT from the{" "} + Authorization: Cloud-IDE-JWT <token> request + header. JWT lifetime is ~14 days. Optional identity fields come from{" "} + common_params in the same request body. +

+
+ +
+ +