diff --git a/.env.example b/.env.example index afac229a..e8ab827b 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ NODE_ENV=production API_KEY_SECRET=endpoint-proxy-api-key-secret MACHINE_ID_SALT=endpoint-proxy-salt ENABLE_REQUEST_LOGS=false +# Console verbosity: DEBUG | INFO | WARN | ERROR. Default INFO. In production set +# ERROR to only print important errors (hides ▶ POST / 📊 DONE / [COMBO] / [CHAT]). +# Can also be changed at runtime from dashboard Settings → Logging. +# LOG_LEVEL=ERROR OBSERVABILITY_ENABLED=true AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false @@ -34,5 +38,8 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# Optional SearXNG endpoint for the built-in unauthenticated web-search provider. +# SEARXNG_URL=http://searxng:8080/search + # Currently unused by application runtime (kept as reference) # INSTANCE_NAME=9router diff --git a/.gitignore b/.gitignore index edd8c086..f748aaf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules /.pnp @@ -9,10 +8,8 @@ !.yarn/plugins !.yarn/releases !.yarn/versions - # testing /coverage - # next.js /.next/ /.next-cli-build/ @@ -22,28 +19,22 @@ product # production /build .idea/ - # misc .DS_Store *.pem - # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* - # env files (can opt-in for committing if needed) .env* !.env.example - # vercel .vercel - # typescript *.tsbuildinfo next-env.d.ts - .bin/* data/ logs/* @@ -61,19 +52,28 @@ Thanks.md PUBLIC.en.md PR/* package-lock.json - - #Ignore vscode AI rules .github/instructions/codacy.instructions.md README1.md deploy*.sh ecosystem.config.* - scripts/agSniffer/* gitbooks/* gitbook/README.md - # Refactor backup reference (do not bundle/lint) open-sse.old/ .graphifyignore graphify-out/* +# Local-only working dirs (notes, vendored repos, scripts, skills) +.claude/ +.docs/ +.repo/ +.script/ +.codegraph/ +.PR/ +.next-analyze/* +# CommandCode CLI local state (auth/taste/projects) +.commandcode/ + +# Pi subagent run artifacts +.pi-subagents/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b03e97..7cb1a186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,304 @@ +# v0.5.55 (2026-08-14) + +## Features +- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS + assertion handling, SP metadata export, admin config test, replay-protected + via a `saml_state` cookie matched against `InResponseTo` +- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the + fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only +- **Providers**: add `glm-5.3` to GLM Coding and GLM (China) +- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a + working Test Connection for both modes +- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants + (also in the Gemini registry) with pricing and quota tracking +- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice + is a `reference_id` (preset or cloned voice model) +- **OpenCode-Go**: route by request format via declared transports instead of + forcing every client into `/messages` — Codex/OpenAI clients no longer pay a + lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats` + guard; the bespoke executor is gone (its shared `_lastModel` cache could cross + auth headers between concurrent requests) +- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token, + in-flight promise dedup, last-good read on soft failure) to stop multiple + tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache + +## Fixes +- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start — + file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a + container with no native driver aborted with ENOENT and never got a database + (#3248) +- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }` + envelope — every non-streaming antigravity request logged `IN 0 | OUT 0` + (#3260) +- **Claude**: re-anchor passthrough cache breakpoints — the client's own + `cache_control` markers point at pre-normalization offsets, so the tail was + re-cached every request. Last system block and last tool pinned at 1h TTL, + last assistant turn at 5m, mid-conversation system messages folded into the + neighbouring user turn instead of hoisted into `body.system` +- **Combos**: detect images from Hermes and attachment payloads (`images[]`, + `experimental_attachments`, message-level `image_url`/`audio_url`, inline + `data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/ + Vercel AI SDK shapes +- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved + `GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit + the now-mandatory initial-response frame and map the `auto` model slot +- **Kiro**: report real output tokens and stop discarding usable turns +- **Qoder**: detect billing blocks at stream start and return a synthetic 403 + so combo/account fallback triggers instead of leaking the error into chat +- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent + prompt) that Antigravity flags with a 429 Quota Exhausted +- **OpenCode**: send the official client fingerprint on free-tier requests so + the Console stops classifying traffic as unidentified and rate-limiting it; + session id resolves conversation-stable to preserve prompt caching +- **Responses**: don't close the message on an empty `tool_calls` array — some + providers attach one to every chunk, and the truthy check ended the message + on the first content token (#3234) +- **Translator**: preserve `prompt_cache_key` when converting chat to responses +- **Models**: expose snake_case token limits on `/v1/models` +- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a + DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and + soft-pass reasoning-only responses (#3010) +- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the + proxy is down — it previously showed OFF while the engine kept calling + `/v1/compress`; proxy status stays visible via the status chip +- **Hermes**: add the `api_key` parameter to the model block in YAML config +- **Providers**: add llm7 to provider test support + +## Docs +- **i18n**: add Spanish, French, and Brazilian Portuguese README translations + +## Security +- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from + client-controlled headers whenever `custom-server.js` was not in the request + path (`npm run start`, `start:bun`), letting a remote caller pose as local to + skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`, + `/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a + per-process `x-9r-peer-token` on every request it sanitizes and only trusts + `x-9r-real-ip` behind it — falling back to Host in development and failing + closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback + detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` / + `start:bun` through `custom-server.js` +- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls + (SSRF guard on `/v1/search`) +- **Login**: fresh-install remote login with the default password returns 403 + without issuing a JWT +- **Usage**: `/api/usage/request-details` redacts request/response payloads + +# v0.5.50 (2026-08-05) + +## Features +- **Providers**: add TokenRouter (300+ models via OpenAI-compatible gateway) with + exact per-model pricing for 110 models and `reasoning_effort` thinking config +- **Providers**: add Self-hosted STT / TTS / Embedding — point 9Router at your own + OpenAI-compatible speech and embedding servers (whisper.cpp, faster-whisper, + Kokoro-FastAPI, llama-server, vLLM, Infinity). Unlike the named cloud providers + these read `baseUrl` per connection, so one provider can front several machines +- **Combos**: default-enable vision/audio capacity adapter (auto-routes to a + vision/audio-capable model when the target lacks that capability, falling back + to `oc/mimo-v2.5-free`), wired into chat handler routing +- **Endpoint**: auto-provision a "Default Key" for first-time users so `/v1` + works without a manual dashboard step +- **Codex**: support GPT-5.6 Max/Ultra reasoning-level overrides (cx/ routes only) +- **Qoder**: support PAT (Personal Access Token) connections end-to-end, alongside + OAuth device flow +- **CLI tools**: add OpenDesign (manalkaff/opendesign) support +- **Headroom**: report effective payload savings (tool schema/history bytes broken + out, byte-savings % reflects actual outbound reduction) +- **Ollama**: Cloud quota tracker (session + weekly) + proactive background OAuth + token refresh scheduler for all providers + +## Fixes +- **Providers**: remove Qwen (OAuth flow stopped working reliably) +- **Passthrough**: detect codex-tui/Codex Desktop as native Codex client — they + were falling through to the translator and losing fields like `reasoning.summary` +- **OAuth**: scope antigravity header fixes to loadCodeAssist/onboardUser only +- **OAuth**: keep `open` external in the build so xAI/Grok token refresh works on + Windows +- **OAuth**: declare missing `searchParams` in register-session handler (was a + 500 instead of JSON on error) +- **DB**: `ENABLE_REQUEST_LOGS` env var now overrides the UI setting correctly; + observability defaults to off (opt-in) +- **Translator**: preserve Codex Responses Lite tool use across chat-native + OpenAI-compatible providers +- **Translator**: don't drop image-only user messages in `prepareClaudeRequest` +- **Translator**: drop JSON Schema keywords Gemini rejects (`uniqueItems`, + `contains`, `multipleOf`, `unevaluatedProperties`, `unevaluatedItems`, + `contentSchema`) +- **Claude**: remove global header cache that leaked one client's identity + headers onto another client/account sharing the server; gate `anthropic-beta` + by model instead +- **Antigravity**: drop retired Gemini 3.0 quota tiers, show Gemini 3.6 Flash + usage bars +- **Cloudflare AI**: declare API key authentication (dashboard showed "No + connections" despite an active key) +- **GitHub Copilot**: hold monthly-exhausted accounts until UTC month reset + instead of only cooling down 120s +- **CodeBuddy**: dodge Tencent CN content filter, add usage tracking, normalize + codebuddy-intl messages +- **Usage**: stop losing cached prompt tokens in the forced-SSE→JSON path +- **Grok CLI**: display the public subscription tier from the OAuth token claim +- **Providers**: count apikey connections for Ollama free-tier card; free-tier/ + apikey providers without `authModes` now default to apikey (were treated + oauth-only) +- **Build**: include static/public assets in standalone output (login page hung + on 404s when run via PM2) +- **Server**: support IntelliJ IDEA OpenAI-compatible clients over HTTP (h2c + upgrade handling) +- **Auth**: redirect already-logged-in sessions away from `/login` +- **CLI tools**: enable Apply button for dynamic OpenAI/Anthropic-compatible + provider connections +- **CLI**: include complete API artifacts in the CLI package +- **TTS**: a bare self-hosted model name is the MODEL, not the voice — `kokoro` + was parsed as a voice against a default model, 404ing or synthesising with the + wrong one +- **Embeddings**: self-hosted embeddings no longer fall back to `api.openai.com` + when a connection has no `baseUrl` — that silently sent the input text and API + key to OpenAI under a provider named "Self-hosted" +- **Embeddings**: an adapter that rejects a misconfigured connection now returns + 400 with the reason instead of escaping the handler uncaught +- **Embeddings**: bound the upstream fetch with `FETCH_CONNECT_TIMEOUT_MS` — an + endpoint that drops packets never returns headers, so the request previously + hung indefinitely + +## Docs +- **i18n**: fix port typo, add RTK Token Saver feature descriptions + +# v0.5.45 (2026-07-30) + +## Features +- **TTS**: add Xiaomi MiMo text-to-speech (preset voices 冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean, style control, language hint dropdown with Auto-detect, i18n for Style label/placeholder) +- **Providers**: add Poolside (OpenAI-compatible) +- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent +- **OAuth**: zed / trae / windsurf providers + harden callback proxies +- **CLI tools**: set Claude Code max context tokens +- **Qoder**: PAT auth + refresh model list +- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite +- **Claude**: bump default Opus to `claude-opus-5` +- **Kiro**: add Claude Opus 5 models +- **Usage**: Kimi and DeepSeek usage handlers +- **Usage**: SuperGrok weekly pool via gRPC-web + +## Fixes +- **Refresh**: rotate `refresh_token` between retry attempts +- **Kiro**: canonicalize tool history and route API keys correctly +- **Kiro**: normalize dashboard thinking intensity models +- **Cursor**: stop leaking agent tool errors as text +- **Gemini**: fill empty tool schemas after `$ref` strip +- **Antigravity**: strip `stream_options` from non-stream requests +- **Jina-reader**: recover after transient errors, use JSON POST API +- **Usage**: record exact embedding tokens +- **Tunnel**: preserve successor cloudflared PID +- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering +- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly +- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow + +## Docs +- **i18n**: expand pt-BR translation to 986 terms +- README: Indonesian translation + +# v0.5.40 (2026-07-20) + +## Features +- **i18n**: add Khmer (km) translations +- **CLI tools**: configure Grok Build subagent models +- **Kimi**: merge OAuth into dual-auth provider, add K3 / K2.7 models +- **Dashboard**: ProviderTopology flow animation + +## Fixes +- **DB**: resolve better-sqlite3 parameter binding crash +- **Translator**: pass `service_tier` through OpenAI → Responses conversion +- **Kiro**: map GPT-5.6 reasoning effort fields +- **Kiro**: validate terminal streams before emitting output +- **Kiro**: map GPT reasoning effort fields +- **Codex**: current `client_version` + refresh-aware model sync +- **Alicode-intl**: split into Coding Plan + Model Studio providers +- **Cursor**: HTTP/2 AgentService support + version bump 3.12.17 +- **Dashboard**: cut duplicate API/icon spam, lazy-load provider assets + + +# v0.5.35 (2026-07-16) + +## Features +- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI +- **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window +- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages` +- **Kiro**: add GPT-5.6 model family (#2596) +- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request +- **Providers**: quota visibility settings +- **Translator**: drop temperature for all Claude models +- **i18n**: Thai (th) + Persian (fa) translations / README + +## Fixes +- **Providers**: bulk-add API keys no longer overwrite existing keys (gap-fill `Key N`) +- **Anthropic**: lowercase `anthropic-version` header to prevent duplication on `/v1/messages` +- **Alicode-intl**: use DashScope compatible-mode endpoint so standard keys work +- **Grok CLI**: align Grok Build with current subscription protocol (#2590) +- **Grok CLI**: surface `expiresAt` so proactive token refresh fires (#2546) +- **Kiro**: improve direct session cache reuse +- **Models**: populate capabilities for live-catalog LLM models +- **Models**: list compatible provider models in `/v1/models` +- **Thinking**: send explicit `thinking:{type:adaptive}` alongside `output_config.effort` +- **Translator**: strip `client_metadata` when converting openai-responses → openai + +## Improvements +- **Perf**: skip inactive background services on startup + +## Docs +- README: Persian YouTube tutorial + +# v0.5.30 (2026-07-10) + +## Features +- **Perplexity**: add Agent API provider (#2492) +- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) +- **Featherless**: add OpenAI-compatible provider presets +- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) +- **Providers**: add max thinking level for gpt-5.6-sol (#2500) +- **Headroom**: add extras detection and install UI (#2403) +- **Headroom**: activate/uninstall extras + fix interpreter detection +- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465) +- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409) + +## Fixes +- **Cloudflare-AI**: support accountId in bulk key import (#2449) +- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM +- **Codex**: avoid bare-email OAuth dedup (#2477) +- **CLI**: allow staged app bundle builds (#2479) +- **Headroom**: compress Kiro conversation state (#2488) +- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486) +- **GitHub**: label Copilot profiles by account identity (#2498) +- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473) +- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466) +- **RTK/find**: detect and group Windows backslash-style find output (#2448) +- **Codex**: handle fast tier and capacity SSE (#2452) +- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap +- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389) +- **Pricing**: update Claude/Codex model rates and add new models + +## Improvements +- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436) +- **API**: caching for tunnel and version status endpoints +- **Perf**: faster dev startup and lighter bundle + +# v0.5.20 (2026-07-07) + +## Features +- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step) +- **RTK**: add JS-native git-log filter (#2423) +- **Caveman**: add targeted upstream-aligned style rules (#2424) +- **i18n**: add Farsi (fa) language support (#2385) + +## Fixes +- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests +- **Translator**: preserve developer instructions in openai-responses conversion (#2434) +- **count_tokens**: count structured Anthropic blocks (#2419) +- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428) +- **Kimi**: normalize reasoning_effort to backend enum (#2427) +- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) +- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) +- **Headroom**: proxy dashboard through app (#2372) +- **MITM**: recover from stale lock file on server start + # v0.5.18 (2026-07-03) ## Features diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d7c21345 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. + +Two published artifacts live in this one repo: +- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing. +- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build. + +The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`. + +## Commands + +Dashboard/gateway (run from repo root): +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev) +npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production +``` +- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`. +- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`). +- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`). + +CLI package (`cli/`): +```bash +npm run cli:pack # build + npm pack from root +cd cli && npm run dev # nodemon watch +``` + +Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`): +```bash +npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc. +cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore) +npx vitest run # all tests; auto-discovers tests/vitest.config.js +npx vitest run unit/capabilities.test.js # single file (path relative to tests/) +``` +> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives. +> +> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red: +> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …). +> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here. +> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked. +> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise. +- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set. +- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic. + +## Architecture + +Two authoritative docs already exist — read them before working in these areas rather than re-deriving: +- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model. +- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.** + +### Request flow (the thing to understand first) +`src/app/api/v1/*` route (Next rewrite maps `/v1/*` → `/api/v1/*` in `next.config.mjs`) +→ `src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop) +→ `open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup) +→ `open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider) +→ `open-sse/translator/*` (client format ↔ provider format) +→ SSE back to client. + +`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously. + +### Translator engine (`open-sse/translator/`) +- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`). +- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs. +- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here. + +### Provider registry (`open-sse/providers/registry/*`) +- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit. +- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams. + +### Persistence — IMPORTANT (ARCHITECTURE.md is stale here) +State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite` → `better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools. +- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`. +- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`). +- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`. + +### RTK token saver (`open-sse/rtk/`) +Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces. + +## Conventions & gotchas + +- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`). +- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code. +- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. +- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. +- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). diff --git a/Dockerfile b/Dockerfile index 5abe1f24..6e11dfe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,9 @@ COPY --from=builder /app/src/mitm ./src/mitm COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge # Ensure `next` is available at runtime in case tracing did not include it. COPY --from=builder /app/node_modules/next ./node_modules/next +# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports, +# so the last-resort DB driver would abort with ENOENT on the missing binary. +COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js RUN mkdir -p /app/data && chown -R node:node /app && \ mkdir -p /app/data-home && chown node:node /app/data-home && \ diff --git a/README.md b/README.md index a9e169a9..2b81116b 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) - decolua%2F9router | Trendshift - - [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) +decolua%2F9router | Trendshift + +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) + +[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md) - [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) --- @@ -83,7 +84,7 @@ npm install -g 9router **2. Connect a FREE provider (no signup needed):** -Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! +Dashboard → Providers → Connect **Kiro AI** (~50 credits/month free: Claude 4.5 + GLM-5 + MiniMax) or **OpenCode Free** (no auth) → Done! **3. Use in your CLI tool:** @@ -114,6 +115,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run ``` Default URLs: + - Dashboard: `http://localhost:20128/dashboard` - OpenAI-compatible API: `http://localhost:20128/v1` @@ -125,6 +127,20 @@ Default URLs: + + - - + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
9Router Setup Tutorial @@ -133,11 +149,11 @@ Default URLs: 9Router + Claude Code FREE Setup
by
Build AI With Hamid
- - Tiết kiệm chi phí LLM với 9Router + + 9Router Setup Tutorial
- 🇻🇳 Tiếng Việt
- Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
@@ -169,8 +185,6 @@ Default URLs: 🇺🇸 English
FREE OpenClaw + Claude Opus 4.6
by
Build AI With Hamid
Claude CLI Free Setup @@ -186,6 +200,25 @@ Default URLs: Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by
Krisswuh
+ + این شکلی از هر API ای استفاده کن برای هوش مصنوعی +
+ 🇮🇷 Persian-فارسی
+ این شکلی از هر API ای استفاده کن برای هوش مصنوعی
by Matin SenPai
+
+ + Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z +
+ 🇻🇳 Tiếng Việt
+ Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z
by tuanminhhole
+
@@ -252,6 +285,32 @@ Default URLs: Kilo Code + + + OpenDesign
+ OpenDesign + + + jcode
+ jcode + + + Grok Build
+ Grok Build + + + Devin CLI
+ Devin CLI + + + DeepSeek TUI
+ DeepSeek TUI + + + Qwen Code
+ Qwen Code + + @@ -300,12 +359,12 @@ Default URLs: Kiro
Kiro AI
- Claude 4.5 + GLM-5 + MiniMax
Unlimited FREE
+ Claude 4.5 + GLM-5 + MiniMax
50 credits/month free
OpenCode Free
OpenCode Free
- No auth • Auto-fetch models
Unlimited FREE
+ No auth • Auto-fetch models
Free (model list varies)
Vertex AI
@@ -316,7 +375,11 @@ Default URLs: -> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. +> **Note:** iFlow, Qwen Code and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. +> +> **Kiro AI** moved to a paid model in Sep 2025 — the free tier is now capped at **50 credits/month** (plus 500 trial credits for new accounts in the first 30 days). Paid tiers: Pro $20/mo (1,000 credits), Pro+ $40/mo (2,000), Pro Max $100/mo (5,000), Power $200/mo (10,000). +> **OpenCode Free** model list fluctuates over time (some models free only for limited promos) — subject to change without notice. +> **Vertex AI**: the $300 free credit for new GCP accounts is still valid, but since Mar 2026 the **Gemini API endpoint no longer consumes these credits** — call the **Vertex AI Studio** endpoint instead. ### 🔑 API Key Providers (40+) @@ -404,26 +467,68 @@ Default URLs:

...and 20+ more providers including Nebius, Chutes, Hyperbolic, and custom OpenAI/Anthropic compatible endpoints

+### 🏠 Self-hosted Providers + +For speech and embeddings served from **your own** machine — whisper.cpp, +faster-whisper, Speaches, Kokoro-FastAPI, openedai-speech, llama.cpp/llama-server, +vLLM, Infinity, text-embeddings-inference, or anything else that speaks the OpenAI +shape. + +| Provider | Endpoint used | Typical server | +| --- | --- | --- | +| **Self-hosted STT** | `/v1/audio/transcriptions` | whisper.cpp, faster-whisper | +| **Self-hosted TTS** | `/v1/audio/speech` | Kokoro-FastAPI, openedai-speech | +| **Self-hosted Embedding** | `/v1/embeddings` | llama-server, vLLM, Infinity | + +Every other speech provider is a named cloud service with a fixed endpoint. These +three read their address from **each connection**, so one provider can front +several machines and load-balance across them like any other. + +Set it on the connection as `providerSpecificData.baseUrl`: + +| Provider | Give it | Result | +| --- | --- | --- | +| Self-hosted STT | the full URL — `http://host:8080/v1/audio/transcriptions` | used as-is | +| Self-hosted TTS | the server root — `http://host:8880` | `+ /v1/audio/speech` | +| Self-hosted Embedding | the **OpenAI base**, `/v1` included — `http://host:8080/v1` | `+ /embeddings` | + +> **Mind the `/v1` on embeddings.** The adapter appends `/embeddings`, so +> `http://host:8080` resolves to `http://host:8080/embeddings` and misses the +> OpenAI route — llama-server answers **501**. Give it the same base URL an OpenAI +> client would use. A full `.../v1/embeddings` is also accepted, so a value pasted +> from a `curl` example works too. + +The API key is not checked by most local servers, but the field must be non-empty: +it is what gives the connection a credentials record, and `baseUrl` lives there. +Any placeholder works. + +Self-hosted Embedding has **no cloud fallback by design** — a connection saved +without a `baseUrl` is reported as a configuration error rather than quietly +falling back to `api.openai.com`, which would send your input text and API key to +a third party under a provider named "Self-hosted". + --- ## 💡 Key Features -| Feature | What It Does | Why It Matters | -|---------|--------------|----------------| -| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | -| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | -| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | -| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | -| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | -| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | -| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | -| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | -| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | -| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | -| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | -| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | -| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | -| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | +| Feature | What It Does | Why It Matters | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | +| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | +| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | +| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | +| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | +| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | +| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | +| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | +| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | +| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | +| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | + +Set `X-9Router-Token-Saver: off` to bypass all token savers for one chat request.
📖 Feature Details @@ -474,7 +579,7 @@ If Headroom is down or returns an error, 9Router fails open and sends the origin ### 🐴 Ponytail (Lazy Senior Dev) -Ponytail injects a *"lazy senior dev"* system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). +Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). - **Lite** — Build what's asked, name the lazier alternative. - **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code. @@ -510,6 +615,7 @@ Combo: "my-coding-stack" ### 🔄 Format Translation Seamless translation between formats: + - **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** - Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format - Works with any tool that supports custom OpenAI endpoints @@ -563,14 +669,14 @@ Seamless translation between formats: - Optimize your AI spending > **💡 IMPORTANT - Understanding Dashboard Costs:** -> -> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. +> +> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. > 9Router itself **never charges** you anything. You only pay providers directly (if using paid services). -> -> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents -> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited). -> -> Think of it as a "savings tracker" showing how much you're saving by using free models or +> +> **Example:** If your dashboard shows "$290 total cost" while using Kiro free models, this represents +> what you would have paid using paid APIs directly. Your actual cost = **$0** (Kiro free tier: ~50 credits/mo). +> +> Think of it as a "savings tracker" showing how much you're saving by using free models or > routing through 9Router! ### 🌐 Deploy Anywhere @@ -586,19 +692,19 @@ Seamless translation between formats: ## 💰 Pricing at a Glance -| Tier | Provider | Cost | Quota Reset | Best For | -|------|----------|------|-------------|----------| -| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | -| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | -| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | -| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| | Cursor IDE | $20/mo | Monthly | Cursor users | -| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | -| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | -| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | -| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | +| Tier | Provider | Cost | Quota Reset | Best For | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | +| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | +| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | +| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | +| | Cursor IDE | $20/mo | Monthly | Cursor users | +| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | + | **🆓 FREE** | Kiro AI | $0 | 50 credits/mo | Claude 4.5 + GLM-5 + MiniMax free (paid tiers above) | + | | OpenCode Free | $0 | Varies* | No auth, auto-fetch models (list changes over time) | + | | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 (use Vertex AI Studio endpoint for free credits) | **💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! @@ -611,7 +717,7 @@ Seamless translation between formats: ✅ **9Router software = FREE forever** (open source, never charges) ✅ **Dashboard "costs" = Display/tracking only** (not actual bills) ✅ **You pay providers directly** (subscriptions or API fees) -✅ **FREE providers stay FREE** (iFlow, Kiro, Qwen = $0 unlimited) +✅ **FREE providers stay FREE** (Kiro ~50 credits/mo, OpenCode Free, Vertex $300 credits = $0 within free-tier limits) — note iFlow/Qwen/Gemini CLI free tiers were discontinued in 2026 ❌ **9Router never sends invoices** or charges your card **How Cost Display Works:** @@ -619,6 +725,7 @@ Seamless translation between formats: The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings. **Example Scenario:** + ``` Dashboard Display: • Total Requests: 1,662 @@ -626,12 +733,13 @@ Dashboard Display: • Display Cost: $290 Reality Check: -• Provider: iFlow (FREE unlimited) +• Provider: Kiro (free tier: ~50 credits/mo) • Actual Payment: $0.00 • What $290 Means: Amount you SAVED by using free models! ``` **Payment Rules:** + - **Subscription providers** (Claude Code, Codex): Pay them directly via their websites - **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes - **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges @@ -646,6 +754,7 @@ Reality Check: **Problem:** Quota expires unused, rate limits during heavy coding **Solution:** + ``` Combo: "maximize-claude" 1. cc/claude-opus-4-7 (use subscription fully) @@ -661,9 +770,10 @@ vs. $20 + hitting limits = frustration **Problem:** Can't afford subscriptions, need reliable AI coding **Solution:** + ``` Combo: "free-forever" - 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo) 2. kr/glm-5 (GLM-5 free via Kiro) 3. oc/ (OpenCode Free, no auth) @@ -676,13 +786,14 @@ Quality: Production-ready models + RTK saves 20-40% tokens **Problem:** Deadlines, can't afford downtime **Solution:** + ``` Combo: "always-on" 1. cc/claude-opus-4-7 (best quality) 2. cx/gpt-5.5 (second subscription) 3. glm/glm-5.1 (cheap, resets daily) 4. minimax/MiniMax-M2.7 (cheapest, 5h reset) - 5. kr/claude-sonnet-4.5 (free unlimited) + 5. kr/claude-sonnet-4.5 (free via Kiro, ~50 credits/mo) Result: 5 layers of fallback = zero downtime Monthly cost: $20-200 (subscriptions) + $10-20 (backup) @@ -693,6 +804,7 @@ Monthly cost: $20-200 (subscriptions) + $10-20 (backup) **Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free **Solution:** + ``` Combo: "openclaw-free" 1. kr/claude-sonnet-4.5 (Claude 4.5 free) @@ -713,8 +825,9 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router. **Example:** + - **Dashboard shows:** "$290 total cost" -- **Reality:** You're using iFlow (FREE unlimited) +- **Reality:** You're using Kiro free models (~50 credits/mo) - **Your actual cost:** **$0.00** - **What $290 means:** Amount you **saved** by using free models instead of paid APIs! @@ -728,6 +841,7 @@ The cost display is a "savings tracker" to help you understand your usage patter **No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything. **You only pay:** + - ✅ **Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites - ✅ **Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests - ❌ **9Router itself** → **Never charges anything, ever** @@ -739,19 +853,21 @@ The cost display is a "savings tracker" to help you understand your usage patter
🆓 Are FREE providers really unlimited? -**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. +**Mostly!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free, but free tiers have limits: These are free services offered by those respective companies: -- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth -- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` -- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) -9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. +- **Kiro AI**: ~50 credits/month free (plus 500 trial credits for new accounts in the first 30 days) via AWS Builder ID / Google / GitHub OAuth. Paid tiers available above that. +- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models`. The free model list fluctuates over time (some models free only for limited promos) — subject to change without notice. +- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days). Since Mar 2026 the Gemini API endpoint no longer consumes these credits — use the **Vertex AI Studio** endpoint instead. + +9Router just routes your requests to them - there's no "catch" or future billing from 9Router itself. They're truly free services, and 9Router makes them easy to use with fallback support. **Discontinued free tiers (no longer recommended):** + - ❌ **iFlow**: Was free unlimited, now changed to paid (2026) -- ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 -- ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself +- ❌ **Qwen Code**: Free OAuth tier fully discontinued by Alibaba on 2026-04-15 +- ❌ **Gemini CLI**: Service fully shut down by Google on 2026-06-18 (replaced by the closed-source Antigravity CLI). Discontinued — do not use.
@@ -761,17 +877,21 @@ These are free services offered by those respective companies: **Free-First Strategy:** 1. **Start with 100% free combo:** + ``` - 1. gc/gemini-3-flash (180K/month free from Google) - 2. if/kimi-k2-thinking (unlimited free from iFlow) - 3. qw/qwen3-coder-plus (unlimited free from Qwen) + 1. kr/glm-5 (GLM-5 free via Kiro, ~50 credits/mo) + 2. OpenCode Free models (no auth, auto-fetched) + 3. Vertex AI Gemini 3 Pro (using the Vertex AI Studio endpoint with $300 credits) ``` - **Cost: $0/month** + + **Cost: $0/month** (within Kiro's free credit cap; OpenCode/Vertex subject to their free-tier limits) 2. **Add cheap backup** only if you need it: + ``` 4. glm/glm-4.7 ($0.6/1M tokens) ``` + **Additional cost: Only pay for what you actually use** 3. **Use subscription providers last:** @@ -790,10 +910,12 @@ These are free services offered by those respective companies: **Scenario:** You're on a coding sprint and blow through your quotas **Without 9Router:** + - ❌ Hit rate limit → Work stops → Frustration - ❌ Or: Accidentally rack up huge API bills **With 9Router:** + - ✅ Subscription hits limit → Auto-fallback to cheap tier - ✅ Cheap tier gets expensive → Auto-fallback to free tier - ✅ Never stop coding → Predictable costs @@ -987,7 +1109,7 @@ Monthly cost example (100M tokens): ``` Name: free-combo Models: - 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo) 2. kr/glm-5 (GLM-5 free via Kiro) 3. vertex/gemini-3.1-pro-preview ($300 free credits) @@ -1117,6 +1239,7 @@ pm2 startup ### Docker Published images (multi-platform `linux/amd64` + `linux/arm64`): + - Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) - GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) @@ -1144,6 +1267,7 @@ docker run -d --name 9router -p 20128:20128 \ ``` **Container defaults:** + - `PORT=20128` - `HOSTNAME=0.0.0.0` @@ -1160,26 +1284,28 @@ docker pull decolua/9router:latest # update to latest ### Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| -| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | -| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | -| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | -| `PORT` | framework default | Service port (`20128` in examples) | -| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | -| `NODE_ENV` | runtime default | Set `production` for deploy | -| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | -| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | -| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | -| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | -| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | -| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | -| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | -| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | +| Variable | Default | Description | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | +| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | +| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint for the built-in unauthenticated SearXNG web-search provider | Notes: + - Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. - `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`. - On Windows, `APPDATA` can be used for local storage path resolution. @@ -1202,6 +1328,7 @@ Notes: View all available models **Claude Code (`cc/`)** - Pro/Max: + - `cc/claude-opus-4-7` - `cc/claude-opus-4-6` - `cc/claude-sonnet-4-6` @@ -1209,6 +1336,7 @@ Notes: - `cc/claude-haiku-4-5-20251001` **Codex (`cx/`)** - Plus/Pro: + - `cx/gpt-5.5` - `cx/gpt-5.4` - `cx/gpt-5.3-codex` @@ -1216,6 +1344,7 @@ Notes: - `cx/gpt-5.1-codex-max` **GitHub Copilot (`gh/`)**: + - `gh/gpt-5.4` - `gh/claude-opus-4.7` - `gh/claude-sonnet-4.6` @@ -1223,25 +1352,30 @@ Notes: - `gh/grok-code-fast-1` **Cursor (`cu/`)** - Subscription: + - `cu/claude-4.6-opus-max` - `cu/claude-4.5-sonnet-thinking` - `cu/gpt-5.3-codex` - `cu/kimi-k2.5` **GLM (`glm/`)** - $0.6/1M: + - `glm/glm-5.1` - `glm/glm-5` - `glm/glm-4.7` **MiniMax (`minimax/`)** - $0.2/1M: + - `minimax/MiniMax-M2.7` - `minimax/MiniMax-M2.5` **Kimi (`kimi/`)** - $9/mo flat: + - `kimi/kimi-k2.5` - `kimi/kimi-k2.5-thinking` -**Kiro (`kr/`)** - FREE unlimited: +**Kiro (`kr/`)** - Free (~50 credits/month, paid tiers above): + - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` - `kr/glm-5` @@ -1250,9 +1384,11 @@ Notes: - `kr/deepseek-3.2` **OpenCode Free (`oc/`)** - FREE no-auth: + - Auto-fetched from `opencode.ai/zen/v1/models` **Vertex AI (`vertex/`)** - $300 free credits: + - `vertex/gemini-3.1-pro-preview` - `vertex/gemini-3-flash-preview` - `vertex/gemini-2.5-flash` @@ -1266,31 +1402,38 @@ Notes: ## 🐛 Troubleshooting **"Language model did not provide messages"** + - Provider quota exhausted → Check dashboard quota tracker - Solution: Use combo fallback or switch to cheaper tier **Rate limiting** + - Subscription quota out → Fallback to GLM/MiniMax - Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` **OAuth token expired** + - Auto-refreshed by 9Router - If issues persist: Dashboard → Provider → Reconnect **High costs** + - Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens) - Check usage stats in Dashboard - Switch primary model to GLM/MiniMax - Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks **Dashboard opens on wrong port** + - Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` **First login not working** + - Check `INITIAL_PASSWORD` in `.env` - If unset, fallback password is `123456` **No request logs under `logs/`** + - Set `ENABLE_REQUEST_LOGS=true` --- @@ -1353,8 +1496,6 @@ Thanks to all contributors who helped make 9Router better! [![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) - - ## 🔀 Forks **[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker. @@ -1367,8 +1508,8 @@ Built on the shoulders of giants: - **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port. - **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request. -- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**. -- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub! diff --git a/README.zh-CN.md b/README.zh-CN.md index 8ba77ffb..1b2bedd2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,7 +82,7 @@ npm install -g 9router **2. 连接免费提供商(无需注册):** -控制面板 → 提供商 → 连接 **Kiro AI**(免费 Claude 无限量)或 **OpenCode Free**(无需认证)→ 完成! +控制面板 → 提供商 → 连接 **Kiro AI**(约 50 积分/月免费:Claude 4.5 + GLM-5 + MiniMax)或 **OpenCode Free**(无需认证)→ 完成! **3. 在 CLI 工具中使用:** @@ -279,12 +279,12 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run Kiro
Kiro AI
- Claude 4.5 + GLM-5 + MiniMax
无限免费
+ Claude 4.5 + GLM-5 + MiniMax
每月 50 积分免费
OpenCode Free
OpenCode Free
- 无需认证 • 自动获取模型
无限免费
+ 无需认证 • 自动获取模型
免费(模型列表会变)
Vertex AI
@@ -295,7 +295,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run -> **注意:** iFlow、Qwen 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 +> **注意:** iFlow、Qwen Code 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 +> +> **Kiro AI** 于 2025 年 9 月转为付费模式 — 免费等级现在上限为**每月 50 积分**(新账户前 30 天另加 500 试用积分)。付费档位:Pro $20/月(1,000 积分)、Pro+ $40/月(2,000)、Pro Max $100/月(5,000)、Power $200/月(10,000)。 +> **OpenCode Free** 的模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更,恕不另行通知。 +> **Vertex AI**:新 GCP 账户的 $300 免费额度仍然有效,但自 2026 年 3 月起 **Gemini API 端点不再消耗这些额度** — 请改用 **Vertex AI Studio** 端点。 ### 🔑 API Key 提供商(40+) @@ -500,7 +504,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run > 使用分析中显示的"成本"**仅用于追踪和比较目的**。 > 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。 > -> **示例:** 如果你的控制面板显示使用 iFlow 模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(iFlow 免费无限量)。 +> **示例:** 如果你的控制面板显示使用 Kiro 免费模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(Kiro 免费等级:约 50 积分/月)。 > > 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱! @@ -527,9 +531,9 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run | **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 | | | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 | | | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 | -| **🆓 免费** | Kiro AI | $0 | 无限量 | Claude 4.5 + GLM-5 + MiniMax 免费 | -| | OpenCode Free | $0 | 无限量 | 无需认证,自动获取模型 | -| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5 | + | **🆓 免费** | Kiro AI | $0 | 50 积分/月 | Claude 4.5 + GLM-5 + MiniMax 免费(之上为付费档位) | + | | OpenCode Free | $0 | varies* | 无需认证,自动获取模型(列表会变化) | + | | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5(使用 Vertex AI Studio 端点消耗免费额度) | **💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**! @@ -542,7 +546,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run ✅ **9Router 软件 = 永久免费**(开源,绝不收费) ✅ **控制面板"成本" = 仅用于显示/追踪**(不是实际账单) ✅ **你直接向提供商付款**(订阅或 API 费用) -✅ **免费提供商保持免费**(iFlow、Kiro、Qwen = $0 无限量) +✅ **免费提供商保持免费**(Kiro 约 50 积分/月、OpenCode Free、Vertex $300 额度 = 在免费额度内 $0)— 注意 iFlow/Qwen/Gemini CLI 免费等级已于 2026 年停止 ❌ **9Router 永不发送发票** 或扣款 **成本显示如何工作:** @@ -557,7 +561,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run • 显示成本:$290 实际检查: -• 提供商:iFlow(免费无限量) +• 提供商:Kiro(免费等级:约 50 积分/月) • 实际支付:$0.00 • $290 意味着什么:通过使用免费模型节省的金额! ``` @@ -565,7 +569,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **付款规则:** - **订阅提供商**(Claude Code、Codex):通过他们的网站直接付款 - **低价提供商**(GLM、MiniMax):直接付款,9Router 只做路由 -- **免费提供商**(iFlow、Kiro、Qwen):真正的永久免费,无隐藏费用 +- **免费提供商**(Kiro、OpenCode Free、Vertex):真正的免费,在免费额度内无隐藏费用 - **9Router**:从不收取任何费用,永远不会 --- @@ -594,7 +598,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **解决方案:** ``` 组合:"free-forever" - 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) 3. oc/ (OpenCode Free,无需认证) @@ -613,7 +617,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run 2. cx/gpt-5.5 (第二个订阅) 3. glm/glm-5.1 (低价,每日重置) 4. minimax/MiniMax-M2.7 (最便宜,5小时重置) - 5. kr/claude-sonnet-4.5 (免费无限量) + 5. kr/claude-sonnet-4.5 (通过 Kiro 免费使用,约 50 积分/月) 结果:5 层切换 = 零停机时间 月成本:$20-200(订阅)+ $10-20(备份) @@ -645,7 +649,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **示例:** - **控制面板显示:** "$290 总成本" -- **实际情况:** 你在使用 iFlow(免费无限量) +- **实际情况:** 你在使用 Kiro 免费模型(约 50 积分/月) - **你的实际成本:** **$0.00** - **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额! @@ -670,19 +674,19 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
🆓 免费提供商真的是无限量的吗? -**是的!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,**无隐藏费用**。 +**基本上是!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,但免费等级有上限: 这些是各公司提供的免费服务: -- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 免费无限量使用 Claude 4.5 + GLM-5 + MiniMax -- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取 -- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天) +- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 使用,免费等级约**每月 50 积分**(新账户前 30 天另加 500 试用积分)。之上提供付费档位。 +- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取。免费模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更。 +- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天)。自 2026 年 3 月起 Gemini API 端点不再消耗这些额度 — 请改用 **Vertex AI Studio** 端点。 9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务,9Router 让它们易于使用并支持切换。 **已停止的免费等级(不再推荐):** - ❌ **iFlow**:曾是免费无限量,现在改为付费(2026) -- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 停止免费 OAuth 等级 -- ❌ **Gemini CLI**:仍可用,但与非 CLI 工具(Claude、Codex、Cursor...)一起使用可能会导致账户被封 — 仅在你坚持使用 Gemini CLI 本身时才使用 +- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 完全停止免费 OAuth 等级 +- ❌ **Gemini CLI**:Google 已于 2026-06-18 完全停止服务(由闭源的 Antigravity CLI 取代)。已停止 — 请勿使用。
@@ -693,11 +697,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run 1. **从 100% 免费组合开始:** ``` - 1. gc/gemini-3-flash (Google 每月 180K 免费) - 2. if/kimi-k2-thinking (iFlow 无限量免费) - 3. qw/qwen3-coder-plus (Qwen 无限量免费) + 1. kr/glm-5 (通过 Kiro 免费使用 GLM-5,约 50 积分/月) + 2. OpenCode Free 模型(无认证,自动获取) + 3. Vertex AI Gemini 3 Pro(使用 Vertex AI Studio 端点 + $300 额度) ``` - **成本:$0/月** + **成本:$0/月**(在 Kiro 免费积分上限内;OpenCode/Vertex 受各自免费等级限制) 2. **仅在需要时添加低价备份:** ``` @@ -918,7 +922,7 @@ Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen) ``` 名称:free-combo 模型: - 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) 3. vertex/gemini-3.1-pro-preview ($300 免费额度) @@ -1168,7 +1172,7 @@ docker stop 9router && docker rm 9router - `kimi/kimi-k2.5` - `kimi/kimi-k2.5-thinking` -**Kiro(`kr/`)** - 免费无限量: +**Kiro(`kr/`)** - 免费(约 50 积分/月,之上为付费档位): - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` - `kr/glm-5` diff --git a/cli/cli.js b/cli/cli.js index 09057b2e..703039dd 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -4,8 +4,28 @@ const { spawn, exec, execSync } = require("child_process"); const path = require("path"); const fs = require("fs"); const https = require("https"); +const net = require("net"); const os = require("os"); +// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits. +function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const tryConnect = () => { + const socket = net.connect({ host: "127.0.0.1", port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + if (Date.now() >= deadline) return resolve(false); + setTimeout(tryConnect, intervalMs); + }); + }; + tryConnect(); + }); +} + // Native spinner - no external dependency function createSpinner(text) { const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -47,6 +67,19 @@ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRunt const { ensureTrayRuntime } = require("./hooks/trayRuntime"); const args = process.argv.slice(2); +// Subcommands (`9router xai video …`) run against an already-running gateway +// and bypass the launcher flow (no runtime self-heal, no server spawn). +if (args[0] === "xai" && args[1] === "video") { + const { run } = require("./src/cli/commands/xaiVideo"); + run(args.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + console.error(`❌ ${err?.message || err}`); + process.exit(1); + }); + return; +} + // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required, // better-sqlite3 is optional. Logs to stderr only on failure. @@ -119,6 +152,11 @@ Options: --skip-update Skip auto-update check -h, --help Show this help message -v, --version Show version + +Commands: + xai video --prompt "..." --output video.mp4 + Generate a Grok Imagine video via the running gateway + (see: ${APP_NAME} xai video --help) `); process.exit(0); } else if (args[i] === "--version" || args[i] === "-v") { @@ -212,17 +250,18 @@ function killCloudflaredByAppPort(appPort) { function killAllAppProcesses(appPort) { return new Promise((resolve) => { try { - // Kill MIT first (privileged process, needs special handling) - killProxyByPidFile(); - // Kill cloudflared/tailscale by PID file (precise, only this app's tunnel) - killTunnelByPidFile(); + // Background: MITM + tunnel/cloudflared run on separate ports/processes — + // killing them doesn't free the app port, so don't block the critical path. + // Server-side MITM manager has stale-lock recovery and starts deferred (~3s). + setImmediate(() => { + try { killProxyByPidFile(); } catch {} + try { killTunnelByPidFile(); } catch {} + try { killCloudflaredByAppPort(appPort); } catch {} + }); const platform = process.platform; let pids = []; - // Catch stale PID files: kill cloudflared bound to this app's port - pids.push(...killCloudflaredByAppPort(appPort)); - if (platform === "win32") { // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it) try { @@ -499,14 +538,11 @@ if (!fs.existsSync(serverPath)) { process.exit(1); } -// Check for updates FIRST, then start server -checkForUpdate().then((latestVersion) => { - killAllAppProcesses(port).then(() => { - return killProcessOnPort(port); - }).then(() => { - startServer(latestVersion); - }); -}); +// Start server immediately; run update check in parallel (not on the critical path). +const updatePromise = checkForUpdate(); +killAllAppProcesses(port) + .then(() => killProcessOnPort(port)) + .then(() => startServer(updatePromise)); // Show interface selection menu async function showInterfaceMenu(latestVersion) { @@ -556,7 +592,9 @@ async function showInterfaceMenu(latestVersion) { const MAX_RESTARTS = 2; const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s -function startServer(latestVersion) { +function startServer(updatePromise) { + // Accept either a Promise (parallel update check) or a resolved value. + const latestVersionPromise = Promise.resolve(updatePromise); const displayHost = getDisplayHost(); const url = `http://${displayHost}:${port}/dashboard`; // Surface real network exposure when bound to all interfaces (default 0.0.0.0). @@ -574,7 +612,7 @@ function startServer(latestVersion) { function spawnServer() { serverStartTime = Date.now(); crashLog = []; - const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], { cwd: standaloneDir, stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"], detached: true, @@ -677,17 +715,19 @@ function startServer(latestVersion) { console.log(`\n🚀 ${pkg.name} v${pkg.version}`); console.log(`Server: http://${displayHost}:${port}`); - setTimeout(() => { + waitServerReady(port).then(() => { initTrayIcon(); console.log("\n💡 Router is now running in system tray. Close this terminal if you want."); console.log(" Right-click tray icon to open dashboard or quit.\n"); - }, 2000); + }); return; } // Wait for server to be ready, then show interface menu loop + tray - setTimeout(async () => { + waitServerReady(port).then(async () => { + // Resolve parallel update check (already running); don't block server start on it. + const latestVersion = await latestVersionPromise; // Start tray icon alongside TUI initTrayIcon(); @@ -745,7 +785,7 @@ function startServer(latestVersion) { // Windows/Linux: spawn detached bgProcess (systray works fine in child) console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`); - const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], { + const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], { detached: true, stdio: "ignore", windowsHide: true, @@ -772,7 +812,7 @@ function startServer(latestVersion) { cleanup(); process.exit(1); } - }, 3000); + }); function attachServerEvents() { server.on("error", (err) => { diff --git a/cli/package.json b/cli/package.json index f55e7751..2fe55c9c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.18", + "version": "0.5.55", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index 625d94ba..2c23d1e7 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -7,7 +7,7 @@ const { execSync } = require("child_process"); const cliDir = path.resolve(__dirname, ".."); const appDir = path.resolve(cliDir, ".."); const rootDir = path.resolve(appDir, ".."); -const cliAppDir = path.join(cliDir, "app"); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); const buildHomeDir = path.join(cliDir, ".build-home"); const buildDistDirName = ".next-cli-build"; const buildDistDir = path.join(appDir, buildDistDirName); @@ -81,201 +81,274 @@ function copyRecursive(src, dest) { } } -console.log("📦 Building 9Router CLI package with Next.js...\n"); +function resolveStandaloneBuild(appDir, buildDistDir) { + const legacyStandaloneRoot = path.join(appDir, ".next", "standalone"); + const resolvedStandaloneRoot = path.join(buildDistDir, "standalone"); + let standaloneRoot = fs.existsSync(resolvedStandaloneRoot) + ? resolvedStandaloneRoot + : legacyStandaloneRoot; -fs.mkdirSync(buildHomeDir, { recursive: true }); -fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); -fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); - -// Step 0: Sync version from app/cli/package.json to app/package.json -console.log("0️⃣ Syncing version to app/package.json..."); -const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); -const appPkgPath = path.join(appDir, "package.json"); -const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); -if (appPkg.version !== cliPkg.version) { - appPkg.version = cliPkg.version; - fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); - console.log(`✅ Version synced: ${cliPkg.version}\n`); -} else { - console.log(`✅ Version already synced: ${cliPkg.version}\n`); -} - -// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). -console.log("1️⃣ Building Next.js app..."); -try { - execSync("npm run build", { - stdio: "inherit", - cwd: appDir, - env: { - ...process.env, - HOME: buildHomeDir, - USERPROFILE: buildHomeDir, - APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), - LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), - NEXT_DIST_DIR: buildDistDirName, - NEXT_TRACING_ROOT_MODE: "workspace", - } - }); - console.log("✅ Next.js build completed\n"); -} catch (error) { - console.error("❌ Next.js build failed"); - process.exit(1); -} - -// Step 2: Clean old app/cli/app if exists -console.log("2️⃣ Cleaning old app/cli/app..."); -if (fs.existsSync(cliAppDir)) { - fs.rmSync(cliAppDir, { recursive: true, force: true }); -} -console.log("✅ Cleaned\n"); - -// Step 3: Copy Next.js standalone build to app/cli/app. -// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and -// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. -console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); -const standaloneRoot = path.join(appDir, ".next", "standalone"); -const standaloneRootResolved = path.join(buildDistDir, "standalone"); -let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; -// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace -// e.g. .next-cli-build/standalone/9router/server.js -const pkgName = path.basename(appDir); -const nestedRoot = path.join(standaloneRootToUse, pkgName); -if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) { - console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`); - standaloneRootToUse = nestedRoot; -} -const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) - ? standaloneRootToUse - : path.join(standaloneRootToUse, "app"); -if (!fs.existsSync(standaloneApp)) { - console.error("❌ Next.js standalone build not found under .next/standalone"); - console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); - process.exit(1); -} -copyRecursive(standaloneApp, cliAppDir); - -// Older nested-app layout stores traced node_modules at standalone root. -const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules"); -if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) { - copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); -} -console.log("✅ Copied standalone build\n"); - -// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). -const customServerSrc = path.join(appDir, "custom-server.js"); -if (fs.existsSync(customServerSrc)) { - fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); - console.log("✅ Copied custom-server.js\n"); -} else { - console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); -} - -// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. -// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid -// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also -// available as a no-install middle tier. -console.log("3️⃣ b Configuring SQLite drivers..."); -function ensureModuleInBundle(pkg) { - const dest = path.join(cliAppDir, "node_modules", pkg); - if (fs.existsSync(dest)) { - console.log(`✅ ${pkg} already bundled`); - return; + // Next.js 16 nests standalone output under the project name when + // NEXT_TRACING_ROOT_MODE=workspace, e.g. standalone/9router/server.js. + const pkgName = path.basename(appDir); + const nestedRoot = path.join(standaloneRoot, pkgName); + if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRoot, "server.js"))) { + console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`); + standaloneRoot = nestedRoot; } - const candidates = [ - path.join(appDir, "node_modules", pkg), - path.join(rootDir, "node_modules", pkg), + + const standaloneApp = fs.existsSync(path.join(standaloneRoot, "server.js")) + ? standaloneRoot + : path.join(standaloneRoot, "app"); + if (!fs.existsSync(standaloneApp)) { + throw new Error( + "Next.js standalone build not found under .next/standalone; " + + "expected either .next/standalone/server.js or .next/standalone/app/", + ); + } + + return { standaloneApp, standaloneRoot }; +} + +function copyStandaloneBuild(appDir, buildDistDir, cliAppDir) { + const { standaloneApp, standaloneRoot } = resolveStandaloneBuild(appDir, buildDistDir); + copyRecursive(standaloneApp, cliAppDir); + + // Older nested-app layout stores traced node_modules at standalone root. + const standaloneNodeModules = path.join(standaloneRoot, "node_modules"); + if (standaloneApp !== standaloneRoot && fs.existsSync(standaloneNodeModules)) { + copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); + } +} + +function mergeServerArtifacts(buildDistDir, cliAppDir) { + const serverSrc = path.join(buildDistDir, "server"); + const serverDest = path.join(cliAppDir, buildDistDirName, "server"); + if (!fs.existsSync(serverSrc)) { + throw new Error(`Complete Next.js server build not found: ${serverSrc}`); + } + copyRecursive(serverSrc, serverDest); +} + +function assertRequiredApiArtifacts(cliAppDir) { + const requiredArtifacts = [ + "app/api/v1/chat/completions/route.js", + "app/api/v1/messages/route.js", ]; - const src = candidates.find((p) => fs.existsSync(p)); - if (!src) { - console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); - return; + const serverDir = path.join(cliAppDir, buildDistDirName, "server"); + const missingArtifacts = requiredArtifacts + .map((artifact) => path.join(serverDir, artifact)) + .filter((artifact) => !fs.existsSync(artifact)); + + if (missingArtifacts.length > 0) { + throw new Error( + `Required CLI API route artifact${missingArtifacts.length === 1 ? " is" : "s are"} missing:\n` + + missingArtifacts.join("\n"), + ); } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - copyRecursive(src, dest); - console.log(`✅ Bundled ${pkg}`); -} -ensureModuleInBundle("sql.js"); -const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); -if (fs.existsSync(betterDir)) { - fs.rmSync(betterDir, { recursive: true, force: true }); - console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); -} -console.log(""); - -// Step 4: Copy static files -console.log("4️⃣ Copying static files..."); -const staticSrc = path.join(appDir, ".next", "static"); -const staticSrcResolved = path.join(buildDistDir, "static"); -const staticDest = path.join(cliAppDir, buildDistDirName, "static"); -if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { - copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); - console.log("✅ Copied static files\n"); -} else { - console.log("⏭️ No static files found\n"); } -// Step 5: Copy public folder if exists -console.log("5️⃣ Copying public folder..."); -const publicSrc = path.join(appDir, "public"); -const publicDest = path.join(cliAppDir, "public"); -if (fs.existsSync(publicSrc)) { - copyRecursive(publicSrc, publicDest); - console.log("✅ Copied public folder\n"); -} else { - console.log("⏭️ No public folder found\n"); +function buildCliPackage() { + console.log("📦 Building 9Router CLI package with Next.js...\n"); + + fs.mkdirSync(buildHomeDir, { recursive: true }); + fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); + fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); + + // Step 0: Sync version from app/cli/package.json to app/package.json + console.log("0️⃣ Syncing version to app/package.json..."); + const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); + const appPkgPath = path.join(appDir, "package.json"); + const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); + if (appPkg.version !== cliPkg.version) { + appPkg.version = cliPkg.version; + fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); + console.log(`✅ Version synced: ${cliPkg.version}\n`); + } else { + console.log(`✅ Version already synced: ${cliPkg.version}\n`); + } + + // Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). + console.log("1️⃣ Building Next.js app..."); + try { + execSync("npm run build", { + stdio: "inherit", + cwd: appDir, + env: { + ...process.env, + HOME: buildHomeDir, + USERPROFILE: buildHomeDir, + APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), + NEXT_DIST_DIR: buildDistDirName, + NEXT_TRACING_ROOT_MODE: "workspace", + } + }); + console.log("✅ Next.js build completed\n"); + } catch (error) { + console.error("❌ Next.js build failed"); + process.exit(1); + } + + // Step 2: Clean old app/cli/app if exists + console.log("2️⃣ Cleaning old app/cli/app..."); + if (fs.existsSync(cliAppDir)) { + fs.rmSync(cliAppDir, { recursive: true, force: true }); + } + console.log("✅ Cleaned\n"); + + // Step 3: Copy Next.js standalone build to app/cli/app. + // Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and + // node_modules/ directly under .next/standalone. Older builds may still use a nested app/. + console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); + try { + copyStandaloneBuild(appDir, buildDistDir, cliAppDir); + } catch (error) { + console.error("❌ Next.js standalone build not found under .next/standalone"); + console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); + process.exit(1); + } + console.log("✅ Copied standalone build\n"); + + // Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). + const customServerSrc = path.join(appDir, "custom-server.js"); + if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); + } else { + console.error("❌ custom-server.js not found — without it no request can be proven local,"); + console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1."); + process.exit(1); + } + + // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. + // Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid + // Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also + // available as a no-install middle tier. + console.log("3️⃣ b Configuring SQLite drivers..."); + function ensureModuleInBundle(pkg) { + const dest = path.join(cliAppDir, "node_modules", pkg); + if (fs.existsSync(dest)) { + console.log(`✅ ${pkg} already bundled`); + return; + } + const candidates = [ + path.join(appDir, "node_modules", pkg), + path.join(rootDir, "node_modules", pkg), + ]; + const src = candidates.find((p) => fs.existsSync(p)); + if (!src) { + console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + copyRecursive(src, dest); + console.log(`✅ Bundled ${pkg}`); + } + ensureModuleInBundle("sql.js"); + // `open` is external (see serverExternalPackages in next.config.mjs), so it must exist in + // the bundle's node_modules or every importer throws MODULE_NOT_FOUND at runtime. Output + // tracing normally copies it; this is the same belt-and-braces guard used for sql.js. + ensureModuleInBundle("open"); + const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); + if (fs.existsSync(betterDir)) { + fs.rmSync(betterDir, { recursive: true, force: true }); + console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); + } + console.log(""); + + // Step 4: Copy static files + console.log("4️⃣ Copying static files..."); + const staticSrc = path.join(appDir, ".next", "static"); + const staticSrcResolved = path.join(buildDistDir, "static"); + const staticDest = path.join(cliAppDir, buildDistDirName, "static"); + if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { + copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); + console.log("✅ Copied static files\n"); + } else { + console.log("⏭️ No static files found\n"); + } + + // Step 5: Copy public folder if exists + console.log("5️⃣ Copying public folder..."); + const publicSrc = path.join(appDir, "public"); + const publicDest = path.join(cliAppDir, "public"); + if (fs.existsSync(publicSrc)) { + copyRecursive(publicSrc, publicDest); + console.log("✅ Copied public folder\n"); + } else { + console.log("⏭️ No public folder found\n"); + } + + // Step 6: Copy vendor-chunks (required for production) + console.log("6️⃣ Copying vendor-chunks..."); + const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); + const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); + const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); + if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { + copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); + console.log("✅ Copied vendor-chunks\n"); + } else { + console.log("⏭️ No vendor-chunks found\n"); + } + + // Step 6b: Merge the complete generated server tree. Next.js standalone output + // is trace-pruned and can omit route modules or chunks loaded dynamically. + console.log("6️⃣ b Copying complete server artifacts..."); + mergeServerArtifacts(buildDistDir, cliAppDir); + assertRequiredApiArtifacts(cliAppDir); + console.log("✅ Copied complete server artifacts\n"); + + // Step 7: Copy MITM server files (not bundled by Next.js standalone) + console.log("7️⃣ Copying MITM server files..."); + const mitmSrc = path.join(appDir, "src", "mitm"); + const mitmDest = path.join(cliAppDir, "src", "mitm"); + if (fs.existsSync(mitmSrc)) { + copyRecursive(mitmSrc, mitmDest); + console.log("✅ Copied MITM files\n"); + } else { + console.log("⏭️ No MITM files found\n"); + } + + // Step 7b: Copy standalone updater (headless Node process for install progress) + console.log("7️⃣ b Copying updater files..."); + const updaterSrc = path.join(appDir, "src", "lib", "updater"); + const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); + if (fs.existsSync(updaterSrc)) { + copyRecursive(updaterSrc, updaterDest); + console.log("✅ Copied updater files\n"); + } else { + console.log("⏭️ No updater files found\n"); + } + + // Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) + console.log("8️⃣ Building MITM server..."); + try { + execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); + console.log("✅ MITM server build completed\n"); + } catch (error) { + console.error("❌ MITM build failed"); + process.exit(1); + } + + console.log("✨ CLI package build completed!"); + console.log(`📁 Output: ${cliAppDir}`); + + try { + const { execSync: exec } = require("child_process"); + const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); + console.log(`📊 Package size: ${size.split("\t")[0]}`); + } catch (e) { + // Silent fail on size check + } } -// Step 6: Copy vendor-chunks (required for production) -console.log("6️⃣ Copying vendor-chunks..."); -const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); -const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); -const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); -if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { - copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); - console.log("✅ Copied vendor-chunks\n"); -} else { - console.log("⏭️ No vendor-chunks found\n"); -} +module.exports = { + assertRequiredApiArtifacts, + copyStandaloneBuild, + mergeServerArtifacts, +}; -// Step 7: Copy MITM server files (not bundled by Next.js standalone) -console.log("7️⃣ Copying MITM server files..."); -const mitmSrc = path.join(appDir, "src", "mitm"); -const mitmDest = path.join(cliAppDir, "src", "mitm"); -if (fs.existsSync(mitmSrc)) { - copyRecursive(mitmSrc, mitmDest); - console.log("✅ Copied MITM files\n"); -} else { - console.log("⏭️ No MITM files found\n"); -} - -// Step 7b: Copy standalone updater (headless Node process for install progress) -console.log("7️⃣ b Copying updater files..."); -const updaterSrc = path.join(appDir, "src", "lib", "updater"); -const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); -if (fs.existsSync(updaterSrc)) { - copyRecursive(updaterSrc, updaterDest); - console.log("✅ Copied updater files\n"); -} else { - console.log("⏭️ No updater files found\n"); -} - -// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) -console.log("8️⃣ Building MITM server..."); -try { - execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); - console.log("✅ MITM server build completed\n"); -} catch (error) { - console.error("❌ MITM build failed"); - process.exit(1); -} - -console.log("✨ CLI package build completed!"); -console.log(`📁 Output: ${cliAppDir}`); - -try { - const { execSync: exec } = require("child_process"); - const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); - console.log(`📊 Package size: ${size.split("\t")[0]}`); -} catch (e) { - // Silent fail on size check +if (require.main === module) { + buildCliPackage(); } diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js index 45c1664c..e47f593a 100644 --- a/cli/scripts/buildMitm.js +++ b/cli/scripts/buildMitm.js @@ -12,7 +12,8 @@ const BUILD_CONFIG = { const cliDir = path.resolve(__dirname, ".."); const appDir = path.resolve(cliDir, ".."); -const cliMitmDir = path.join(cliDir, "app", "src", "mitm"); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); +const cliMitmDir = path.join(cliAppDir, "src", "mitm"); // Bundle everything — no externals. This keeps MITM runtime self-contained so // it can be copied to DATA_DIR/runtime/ and spawned from there (escapes // node_modules file locks that block `npm i -g 9router@latest` on Windows). diff --git a/cli/src/cli/commands/xaiVideo.js b/cli/src/cli/commands/xaiVideo.js new file mode 100644 index 00000000..27d829f7 --- /dev/null +++ b/cli/src/cli/commands/xaiVideo.js @@ -0,0 +1,300 @@ +/** + * `9router xai video` — generate a Grok Imagine video through the local + * 9router gateway and save the result as an MP4 file. + * + * Flow: POST /v1/videos/generations → poll GET /v1/videos/{request_id} + * until done/failed/timeout → download video.url → atomic rename. + * + * No OAuth tokens or Authorization headers are ever printed. + */ + +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_PORT = 20128; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_MODEL = "xai/grok-imagine-video"; +const DEFAULT_TIMEOUT_SEC = 600; +const DEFAULT_POLL_INTERVAL_MS = 5000; + +const TERMINAL_STATUSES = new Set(["done", "failed", "completed", "error", "expired", "cancelled"]); +const FAILED_STATUSES = new Set(["failed", "error", "expired", "cancelled"]); + +const HELP = ` +Usage: 9router xai video --prompt "..." [options] + +Generate a Grok Imagine video via your local 9router gateway +(requires a connected xAI account — Grok Build OAuth or API key). + +Options: + --prompt Video description (required) + --output Output MP4 path (default: video.mp4) + --model Model (default: ${DEFAULT_MODEL}) + --duration Video duration + --aspect-ratio e.g. 16:9, 9:16, 1:1 + --resolution 480p | 720p | 1080p + --image Image input for image-to-video + --timeout Max wait for the job (default: ${DEFAULT_TIMEOUT_SEC}) + --port Gateway port (default: ${DEFAULT_PORT}) + --host Gateway host (default: ${DEFAULT_HOST}) + --api-key 9router API key (or env NINE_ROUTER_API_KEY) + -h, --help Show this help +`; + +function sanitizeText(text) { + return String(text ?? "").replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]"); +} + +function parseArgs(argv) { + const opts = { + model: DEFAULT_MODEL, + output: "video.mp4", + timeoutSec: DEFAULT_TIMEOUT_SEC, + port: DEFAULT_PORT, + host: DEFAULT_HOST, + apiKey: process.env.NINE_ROUTER_API_KEY || null, + pollIntervalMs: DEFAULT_POLL_INTERVAL_MS, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + if (a === "--prompt") opts.prompt = next(); + else if (a === "--output" || a === "-o") opts.output = next(); + else if (a === "--model") opts.model = next(); + else if (a === "--duration") opts.duration = parseInt(next(), 10); + else if (a === "--aspect-ratio") opts.aspectRatio = next(); + else if (a === "--resolution") opts.resolution = next(); + else if (a === "--image") opts.image = next(); + else if (a === "--timeout") opts.timeoutSec = parseInt(next(), 10) || DEFAULT_TIMEOUT_SEC; + else if (a === "--port" || a === "-p") opts.port = parseInt(next(), 10) || DEFAULT_PORT; + else if (a === "--host" || a === "-H") opts.host = next() || DEFAULT_HOST; + else if (a === "--api-key") opts.apiKey = next(); + else if (a === "--poll-interval-ms") opts.pollIntervalMs = parseInt(next(), 10) || DEFAULT_POLL_INTERVAL_MS; + else if (a === "-h" || a === "--help") opts.help = true; + else { + throw new Error(`Unknown option: ${a}`); + } + } + return opts; +} + +/** Local file path → base64 data URL; URLs pass through untouched. */ +function imageInputToUrl(input) { + if (/^(https?:|data:)/i.test(input)) return input; + const buf = fs.readFileSync(input); + const ext = path.extname(input).toLowerCase(); + const mime = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg"; + return `data:${mime};base64,${buf.toString("base64")}`; +} + +/** Minimal JSON request against the local gateway. Returns { status, headers, body }. */ +function gatewayRequest({ host, port, apiKey, method, reqPath, body, signal }) { + return new Promise((resolve, reject) => { + const payload = body ? JSON.stringify(body) : null; + const headers = { Accept: "application/json" }; + if (payload) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = Buffer.byteLength(payload); + } + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + + const req = http.request({ hostname: host, port, path: reqPath, method, headers, signal }, (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => { + let parsed = null; + try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ } + resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: data }); + }); + }); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +const sleep = (ms, signal) => + new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener?.("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true }); + }); + +/** + * Poll GET /v1/videos/{id} until a terminal status or deadline. + * @returns {Promise} final poll body (status done) — throws on failed/timeout. + */ +async function pollUntilDone({ host, port, apiKey, requestId, connectionId, timeoutSec, pollIntervalMs, signal, onProgress }) { + const deadline = Date.now() + timeoutSec * 1000; + while (true) { + if (signal?.aborted) throw new Error("aborted"); + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeoutSec}s waiting for video job ${requestId}`); + } + + const res = await gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal }); + if (res.status === 200 && res.body) { + const status = String(res.body.status || "").toLowerCase(); + onProgress?.(status || "pending", res.body.progress); + if (FAILED_STATUSES.has(status)) { + const msg = res.body.error?.message || res.body.error || "video generation failed"; + throw new Error(`Job ${requestId} failed: ${sanitizeText(typeof msg === "string" ? msg : JSON.stringify(msg))}`); + } + if (TERMINAL_STATUSES.has(status)) return res.body; + } else if (res.status >= 400 && res.status !== 429 && res.status !== 503) { + throw new Error(`Polling failed (HTTP ${res.status}): ${sanitizeText(res.raw?.slice(0, 300))}`); + } + await sleep(pollIntervalMs, signal); + } +} + +function gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal }) { + return new Promise((resolve, reject) => { + const headers = { Accept: "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + if (connectionId) headers["x-connection-id"] = connectionId; + const req = http.request( + { hostname: host, port, path: `/v1/videos/${encodeURIComponent(requestId)}`, method: "GET", headers, signal }, + (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => { + let parsed = null; + try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ } + resolve({ status: res.statusCode, body: parsed, raw: data }); + }); + } + ); + req.on("error", reject); + req.end(); + }); +} + +/** + * Download a URL to `outputPath` via a `.part` temp file with atomic rename. + * The temp file is removed on any failure. + */ +async function downloadToFile(url, outputPath, { signal } = {}) { + const partPath = `${outputPath}.part`; + await new Promise((resolve, reject) => { + const cleanupAnd = (fn) => (err) => { + try { fs.unlinkSync(partPath); } catch { /* not created yet */ } + fn(err); + }; + const get = (target, redirectsLeft) => { + const mod = target.startsWith("https:") ? https : http; + const req = mod.get(target, { signal }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) { + res.resume(); + return get(new URL(res.headers.location, target).toString(), redirectsLeft - 1); + } + if (res.statusCode !== 200) { + res.resume(); + return cleanupAnd(reject)(new Error(`Download failed: HTTP ${res.statusCode}`)); + } + const out = fs.createWriteStream(partPath); + res.pipe(out); + out.on("finish", () => out.close(resolve)); + out.on("error", cleanupAnd(reject)); + res.on("error", cleanupAnd(reject)); + }); + req.on("error", cleanupAnd(reject)); + }; + get(url, 5); + }); + fs.renameSync(partPath, outputPath); +} + +async function run(argv) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`❌ ${err.message}`); + console.log(HELP); + return 1; + } + if (opts.help) { + console.log(HELP); + return 0; + } + if (!opts.prompt) { + console.error("❌ --prompt is required"); + console.log(HELP); + return 1; + } + + const controller = new AbortController(); + const partPath = `${opts.output}.part`; + const onSigint = () => { + controller.abort(); + try { fs.unlinkSync(partPath); } catch { /* absent */ } + console.error("\n✋ Cancelled"); + process.exit(130); + }; + process.on("SIGINT", onSigint); + + try { + const body = { model: opts.model, prompt: opts.prompt }; + if (opts.duration) body.duration = opts.duration; + if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio; + if (opts.resolution) body.resolution = opts.resolution; + if (opts.image) body.image = { url: imageInputToUrl(opts.image) }; + + console.log(`🎬 Requesting video (${opts.model})…`); + const create = await gatewayRequest({ + host: opts.host, port: opts.port, apiKey: opts.apiKey, + method: "POST", reqPath: "/v1/videos/generations", body, signal: controller.signal, + }); + + if (create.status !== 200 || !create.body?.request_id) { + const detail = create.body?.error?.message || create.body?.error || create.raw || `HTTP ${create.status}`; + console.error(`❌ Create failed: ${sanitizeText(typeof detail === "string" ? detail : JSON.stringify(detail)).slice(0, 500)}`); + if (create.status === 400 && /No credentials/i.test(String(detail))) { + console.error(" Connect an xAI account first: dashboard → Providers → xAI (Grok)."); + } + return 1; + } + + const requestId = create.body.request_id; + const connectionId = create.headers["x-9router-connection-id"] || null; + console.log(`📋 Job accepted: ${requestId}`); + + let lastLine = ""; + const result = await pollUntilDone({ + host: opts.host, port: opts.port, apiKey: opts.apiKey, + requestId, connectionId, + timeoutSec: opts.timeoutSec, pollIntervalMs: opts.pollIntervalMs, + signal: controller.signal, + onProgress: (status, progress) => { + const line = `⏳ ${status}${Number.isFinite(progress) ? ` ${progress}%` : ""}`; + if (line !== lastLine) { + lastLine = line; + if (process.stdout.isTTY) process.stdout.write(`\r\x1b[K${line}`); + else console.log(line); + } + }, + }); + if (process.stdout.isTTY) process.stdout.write("\n"); + + const videoUrl = result.video?.url || result.video?.file_output?.public_url; + if (!videoUrl) { + console.error("❌ Job finished but no video URL was returned"); + return 1; + } + + console.log("⬇️ Downloading…"); + await downloadToFile(videoUrl, opts.output, { signal: controller.signal }); + console.log(`✅ Saved ${opts.output}`); + return 0; + } catch (err) { + if (process.stdout.isTTY) process.stdout.write("\n"); + console.error(`❌ ${sanitizeText(err?.message || String(err))}`); + return 1; + } finally { + process.removeListener("SIGINT", onSigint); + } +} + +module.exports = { run, parseArgs, pollUntilDone, downloadToFile, imageInputToUrl, sanitizeText }; diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 57013466..92d10a04 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,12 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.7-flash-high" }, + { id: "gemini-3.7-flash-medium" }, + { id: "gemini-3.7-flash-low" }, + { id: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low" }, { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-extra-low" }, @@ -95,6 +101,8 @@ const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022" }, ], gemini: [ + { id: "gemini-3.6-flash" }, + { id: "gemini-3.5-flash-lite" }, { id: "gemini-3-pro-preview" }, { id: "gemini-2.5-pro" }, { id: "gemini-2.5-flash" }, @@ -131,7 +139,7 @@ const APIKEY_PROVIDERS = { openrouter: { id: "openrouter", name: "OpenRouter" }, glm: { id: "glm", name: "GLM Coding" }, minimax: { id: "minimax", name: "Minimax Coding" }, - kimi: { id: "kimi", name: "Kimi Coding" }, + kimi: { id: "kimi", name: "Kimi" }, openai: { id: "openai", name: "OpenAI" }, anthropic: { id: "anthropic", name: "Anthropic" }, gemini: { id: "gemini", name: "Gemini" }, diff --git a/custom-server.js b/custom-server.js index 6e39683f..ba092136 100644 --- a/custom-server.js +++ b/custom-server.js @@ -1,7 +1,51 @@ const http = require("http"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const { pathToFileURL } = require("url"); const origCreate = http.createServer.bind(http); +// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client. +// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching +// header even though the env var is inherited by child processes. Named like x-9r-cli-token +// so the request-detail header sanitizer redacts it too. +const PEER_TOKEN = crypto.randomBytes(24).toString("hex"); +process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; + +let backgroundRefreshStarted = false; + +function startBackgroundTokenRefreshFromCustomServer() { + if (backgroundRefreshStarted) return; + backgroundRefreshStarted = true; + // Prefer source path (repo / standalone that still has src). Fail-open if missing + // — initializeApp also starts the same scheduler when the Next app boots. + const modPath = path.join(__dirname, "src", "sse", "services", "backgroundTokenRefresh.js"); + import(pathToFileURL(modPath).href) + .then((m) => { + try { + m.startBackgroundTokenRefresh(); + } catch (e) { + console.error("[BackgroundTokenRefresh] start failed:", e && e.message ? e.message : e); + } + const stop = () => { + try { + m.stopBackgroundTokenRefresh(); + } catch { + /* ignore */ + } + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }) + .catch((e) => { + // Expected in published CLI standalone (src/ not on disk). App bootstrap covers it. + if (process.env.DEBUG_BACKGROUND_TOKEN_REFRESH) { + console.error("[BackgroundTokenRefresh] import failed:", e && e.message ? e.message : e); + } + }); +} + // Wrap Next standalone HTTP server: derive client IP from the TCP socket // (unspoofable) and strip client-supplied forwarding headers so downstream // rate-limiting keys on the real peer address instead of attacker-controlled XFF. @@ -22,11 +66,74 @@ http.createServer = (...args) => { delete req.headers["x-9r-real-ip"]; delete req.headers["x-forwarded-for"]; delete req.headers["x-9r-via-proxy"]; + delete req.headers["x-9r-peer-token"]; req.headers["x-9r-real-ip"] = ip; + req.headers["x-9r-peer-token"] = PEER_TOKEN; if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; return handler(req, res); }; - return origCreate(...rest, wrapped); + const server = origCreate(...rest, wrapped); + server.once("listening", () => { + startBackgroundTokenRefreshFromCustomServer(); + }); + const origEmit = server.emit; + // JBR 25 sends h2c upgrades that the HTTP/1.1 server would otherwise close. + server.emit = function (event, ...eventArgs) { + const [req, socket, head] = eventArgs; + if (event !== "upgrade" || String(req.headers.upgrade || "").toLowerCase() !== "h2c") { + return origEmit.call(this, event, ...eventArgs); + } + + const contentLength = Number(req.headers["content-length"] || 0); + if (!Number.isSafeInteger(contentLength) || contentLength < 0) { + socket.destroy(); + return true; + } + const chunks = [head]; + let received = head.length; + const serve = () => { + // Replay the upgraded request through the existing HTTP/1.1 handler. + const replay = new http.IncomingMessage(socket); + Object.assign(replay, { method: req.method, url: req.url, headers: req.headers, complete: true }); + if (received) replay.push(Buffer.concat(chunks, received).subarray(0, contentLength)); + replay.push(null); + const res = new http.ServerResponse(replay); + res.shouldKeepAlive = false; + res.assignSocket(socket); + res.once("finish", () => socket.end()); + Promise.resolve().then(() => wrapped(replay, res)).catch((error) => { + console.error("Failed to downgrade h2c request", error); + socket.destroy(); + }); + }; + if (received >= contentLength) serve(); + else { + socket.on("data", function readBody(chunk) { + chunks.push(chunk); + received += chunk.length; + if (received < contentLength) return; + socket.off("data", readBody); + serve(); + }); + socket.resume(); + } + delete req.headers.upgrade; + delete req.headers["http2-settings"]; + req.headers.connection = "close"; + return true; + }; + return server; }; -require("./server.js"); +if (require.main === module) { + const standalone = path.join(__dirname, "server.js"); + if (fs.existsSync(standalone)) { + require(standalone); + } else { + // Repo checkout has no standalone build next to us. `next start` builds its HTTP + // server in-process, so the wrapper above still sanitizes every request. + const nextBin = require.resolve("next/dist/bin/next"); + process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)]; + require(nextBin); + } +} diff --git a/docs/images/saml-admin-dashboard.png b/docs/images/saml-admin-dashboard.png new file mode 100644 index 00000000..53d0466e Binary files /dev/null and b/docs/images/saml-admin-dashboard.png differ diff --git a/docs/images/saml-login-screen.png b/docs/images/saml-login-screen.png new file mode 100644 index 00000000..b1995af1 Binary files /dev/null and b/docs/images/saml-login-screen.png differ diff --git a/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md b/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md new file mode 100644 index 00000000..8ff5a940 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md @@ -0,0 +1,328 @@ +# GPT-5.6 Codex Reasoning Overrides Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve Codex-advertised Max and Ultra overrides for GPT-5.6 Sol and Terra, preserve Max for Luna, and convert Luna Ultra to Max without changing Kiro or generic OpenAI-format behavior. + +**Architecture:** Keep the supported reasoning matrix in the existing `getThinkingLevels(provider, model)` resolver and reuse that result in both translation and Codex executor normalization. The dashboard already consumes this resolver, so no UI component change is required. Unsupported top-end levels remain safely normalized, with Luna Ultra selecting Luna's supported Max level. + +**Tech Stack:** JavaScript ES modules, Next.js, Vitest, Codex Responses transport. + +## Global Constraints + +- Apply the new overrides only to the OpenAI Codex provider (`codex`, exposed as `cx/`). +- Sol and Terra support `max` and `ultra`; Luna supports `max` but not `ultra`. +- Convert Luna `ultra` requests to `max` in both translated and native passthrough request paths. +- Preserve existing Kiro and generic OpenAI-compatible normalization. +- Do not add runtime model-catalog fetching, dependencies, pricing changes, or unrelated refactors. +- Write each behavior test first and observe the expected failure before changing production code. + +--- + +### Task 1: Provider-scoped GPT-5.6 level matrix + +**Files:** +- Modify: `tests/unit/thinking-levels-gpt56-sol.test.js` +- Modify: `open-sse/providers/thinkingLevels.js` + +**Interfaces:** +- Consumes: `getThinkingLevels(provider, model)` and existing capability metadata. +- Produces: `getThinkingLevels(provider, model): string[] | null` with Codex-only GPT-5.6 level overrides. + +- [ ] **Step 1: Replace the Sol-only assertions with the complete behavior matrix** + +Use literal expected arrays so each model/provider contract is independently checked: + +```js +it.each([ + ["gpt-5.6-sol", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], + ["gpt-5.6-sol-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], +])("returns Codex levels for %s", (model, expected) => { + expect(getThinkingLevels("codex", model)).toEqual(expected); +}); + +it("does not expose Codex-only GPT-5.6 overrides on Kiro", () => { + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toEqual([ + "none", "minimal", "low", "medium", "high", "xhigh", + ]); +}); +``` + +Keep the older Codex-model assertion to protect the existing `gpt-5.3-codex` behavior. + +- [ ] **Step 2: Run the level test and verify it fails for the missing matrix/provider scoping** + +Run: + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js +``` + +Expected: FAIL because Sol lacks Ultra, Terra/Luna lack Max, and Kiro currently inherits Sol Max. + +- [ ] **Step 3: Add provider-aware pattern matching and the three Codex model rules** + +Update `PATTERN_THINKING` entries to accept an optional `provider` field and match it in `getThinkingLevels`: + +```js +const CODEX_GPT_5_6_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +const PATTERN_THINKING = [ + { provider: "codex", pattern: "*gpt-5.6-sol*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, + { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, +]; + +const hit = PATTERN_THINKING.find((entry) => + (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) +); +``` + +- [ ] **Step 4: Re-run the level test and verify it passes** + +Run: + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js +``` + +Expected: 1 test file passed with no failures. + +- [ ] **Step 5: Commit the capability matrix** + +```bash +git add open-sse/providers/thinkingLevels.js tests/unit/thinking-levels-gpt56-sol.test.js +git commit -m "feat(codex): expose GPT-5.6 reasoning overrides" +``` + +### Task 2: Model-aware shared thinking translation + +**Files:** +- Modify: `tests/translator/thinking-unified.test.js` +- Modify: `open-sse/translator/concerns/thinkingUnified.js` + +**Interfaces:** +- Consumes: `getThinkingLevels(provider, cleanModel): string[] | null` from Task 1. +- Produces: `parseSuffix(model)` support for `ultra` and `applyThinking(...)` output that preserves supported Codex levels. + +- [ ] **Step 1: Add failing suffix and translation tests** + +Add a literal parser assertion: + +```js +expect(parseSuffix("gpt-5.6-sol(ultra)")).toEqual({ + cleanModel: "gpt-5.6-sol", + override: { mode: "level", level: "ultra" }, +}); +``` + +Add table-driven Codex assertions using direct request fields: + +```js +it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], +])("normalizes Codex %s effort %s to %s", (model, effort, expected) => { + const out = apply("openai-responses", model, { reasoning: { effort } }, "codex"); + expect(out.reasoning_effort).toBe(expected); +}); +``` + +Add a parenthesized override assertion and Kiro isolation assertion: + +```js +expect(apply("openai-responses", "gpt-5.6-sol(ultra)", {}, "codex").reasoning_effort).toBe("ultra"); +expect(apply("openai", "gpt-5.6-sol", { reasoning_effort: "max" }, "kiro").reasoning_effort).toBe("xhigh"); +``` + +- [ ] **Step 2: Run the translator test and verify it fails for Ultra parsing and preserved Max/Ultra** + +Run: + +```bash +npx vitest run tests/translator/thinking-unified.test.js +``` + +Expected: FAIL because Ultra suffixes are ignored and OpenAI translation clamps Max to XHigh. + +- [ ] **Step 3: Implement supported-level normalization in the shared translator** + +Import `getThinkingLevels`. Recognize `ultra` explicitly in `parseSuffix` without adding it to the budget map. Resolve supported levels once in `applyThinking` and pass them to `applyFormat`. + +Use this normalization rule for the OpenAI format: + +```js +function normalizeOpenAILevel(level, supportedLevels) { + if (level !== "max" && level !== "ultra") return level; + if (supportedLevels?.includes(level)) return level; + if (level === "ultra" && supportedLevels?.includes("max")) return "max"; + return "xhigh"; +} +``` + +Keep `none`, automatic effort, budget conversion, and every non-OpenAI format unchanged. + +- [ ] **Step 4: Re-run the translator and generic OpenAI clamp tests** + +Run: + +```bash +npx vitest run tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js +``` + +Expected: 2 test files passed; generic OpenAI Max still becomes XHigh. + +- [ ] **Step 5: Commit shared translation support** + +```bash +git add open-sse/translator/concerns/thinkingUnified.js tests/translator/thinking-unified.test.js +git commit -m "feat(codex): preserve supported reasoning efforts" +``` + +### Task 3: Codex native passthrough normalization + +**Files:** +- Modify: `tests/unit/codex-fast-capacity.test.js` +- Modify: `open-sse/executors/codex.js` + +**Interfaces:** +- Consumes: `getThinkingLevels("codex", upstreamModel): string[] | null` from Task 1. +- Produces: `CodexExecutor.transformRequest(...)` payloads with model-supported upstream `reasoning.effort` values. + +- [ ] **Step 1: Add failing Codex executor behavior tests** + +Add a separate `describe("Codex reasoning normalization", ...)` block with real `transformRequest` calls: + +```js +it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], +])("normalizes %s effort %s to %s", (model, effort, expected) => { + const body = new CodexExecutor().transformRequest(model, { + model, + input: "hi", + reasoning: { effort }, + }, true, {}); + expect(body.reasoning.effort).toBe(expected); +}); + +it("resolves review models before applying the reasoning matrix", () => { + const body = new CodexExecutor().transformRequest("gpt-5.6-terra-review", { + model: "gpt-5.6-terra-review", + input: "hi", + reasoning_effort: "ultra", + }, true, {}); + expect(body.model).toBe("gpt-5.6-terra"); + expect(body.reasoning.effort).toBe("ultra"); +}); +``` + +Keep the existing GPT-5.5 Max-to-XHigh fast-tier test. + +- [ ] **Step 2: Run the executor test and verify supported values fail by being clamped** + +Run: + +```bash +npx vitest run tests/unit/codex-fast-capacity.test.js +``` + +Expected: FAIL because current normalization maps supported Max to XHigh and does not map Luna Ultra to Max. + +- [ ] **Step 3: Make Codex normalization model-aware** + +Import `getThinkingLevels` and replace the global Max clamp with: + +```js +function normalizeReasoningEffort(model, value) { + const supportedLevels = getThinkingLevels("codex", model); + if (supportedLevels?.includes(value)) return value; + if (value === "ultra" && supportedLevels?.includes("max")) return "max"; + if (value === "max" || value === "ultra") return "xhigh"; + return value; +} +``` + +Call it only after `body.model` has resolved review aliases to their upstream base model. Pass `body.model` for both `reasoning_effort` and existing `reasoning.effort` request shapes. + +- [ ] **Step 4: Re-run the executor and focused feature suites** + +Run: + +```bash +npx vitest run tests/unit/codex-fast-capacity.test.js tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js +``` + +Expected: 4 test files passed with no failures. + +- [ ] **Step 5: Commit native Codex normalization** + +```bash +git add open-sse/executors/codex.js tests/unit/codex-fast-capacity.test.js +git commit -m "feat(codex): forward GPT-5.6 max and ultra efforts" +``` + +### Task 4: Full verification and pull request + +**Files:** +- Verify all changed production, test, design, and plan files. + +**Interfaces:** +- Consumes: completed Tasks 1-3. +- Produces: verified branch pushed to `origin` and a pull request targeting `decolua/9router:master`. + +- [ ] **Step 1: Run all focused regression tests** + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js tests/unit/codex-fast-capacity.test.js +``` + +Expected: all selected test files and tests pass. + +- [ ] **Step 2: Run the complete unit test suite** + +```bash +npx vitest run tests/unit tests/translator +``` + +Expected: all test files pass with zero failed tests. + +- [ ] **Step 3: Run the production build** + +```bash +npm run build +``` + +Expected: Next.js production build exits with status 0. + +- [ ] **Step 4: Verify repository hygiene and requirement coverage** + +```bash +git diff --check upstream/master...HEAD +git status --short --branch +git log --oneline upstream/master..HEAD +``` + +Expected: no whitespace errors, no uncommitted source changes, and only scoped feature commits. + +- [ ] **Step 5: Push the feature branch and open the pull request** + +```bash +git push -u origin codex/gpt-5-6-reasoning-overrides +gh pr create --repo decolua/9router --base master --head seakleangnhak:codex/gpt-5-6-reasoning-overrides --title "feat(codex): support GPT-5.6 Max and Ultra overrides" --body $'## Summary\n- expose Max and Ultra for Codex GPT-5.6 Sol and Terra\n- expose Max for Codex GPT-5.6 Luna and normalize Luna Ultra to Max\n- keep Kiro and generic OpenAI-compatible reasoning behavior unchanged\n\n## Verification\n- `npx vitest run tests/unit tests/translator`\n- `npm run build`' +``` + +The pull request body must summarize the Codex-only support matrix, Luna Ultra-to-Max fallback, Kiro isolation, and fresh test/build evidence. diff --git a/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md b/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md new file mode 100644 index 00000000..832fb5ce --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md @@ -0,0 +1,122 @@ +# GPT-5.6 Codex Reasoning Overrides Design + +## Goal + +Expose and preserve the reasoning levels currently advertised by the OpenAI +Codex model catalog for GPT-5.6 Sol, Terra, and Luna when they are routed +through the `codex` provider (`cx/`). + +The supported override matrix is: + +| Model family | Max | Ultra | +| --- | --- | --- | +| GPT-5.6 Sol | Yes | Yes | +| GPT-5.6 Terra | Yes | Yes | +| GPT-5.6 Luna | Yes | No | + +The same matrix applies to 9router's virtual `-review` variants because they +resolve to the corresponding upstream base model. + +## Scope + +This change is limited to OpenAI Codex (`cx/`) routes. Kiro (`kr/`) and other +OpenAI-format providers retain their existing reasoning-level behavior even +when they expose models with the same GPT-5.6 names. + +The change covers the complete local request path: + +1. The provider page advertises only the levels supported by each Codex model. +2. A copied model suffix such as `gpt-5.6-sol(ultra)` is parsed as a reasoning + override. +3. The shared thinking translator preserves a supported Codex override while + retaining the existing `xhigh` fallback for unsupported OpenAI levels. +4. The Codex executor sends supported `max` and `ultra` values unchanged to the + upstream Codex Responses endpoint. + +## Current Behavior + +`gpt-5.6-luna` and the other GPT-5.6 models already exist in the Codex model +registry. The capability picker has a global Sol-only `max` pattern, which also +affects providers such as Kiro unintentionally. The shared OpenAI translator +and Codex executor then convert `max` to `xhigh`, so the advertised override is +not preserved end to end. `ultra` is not recognized as a model suffix. + +## Design + +### Provider-scoped level resolution + +Extend the existing model-pattern overrides in +`open-sse/providers/thinkingLevels.js` with an optional provider constraint. +Add three Codex-only GPT-5.6 patterns in most-specific order: + +- Sol: existing levels plus `max` and `ultra`. +- Terra: existing levels plus `max` and `ultra`. +- Luna: existing levels plus `max`. + +Matching remains wildcard-based so virtual `-review` variants inherit the +base model's levels. Provider matching prevents these overrides from changing +Kiro or other providers. + +### Shared translation + +Teach the suffix parser to recognize `ultra` as a discrete level without +assigning it a synthetic token budget. When applying the OpenAI wire format, +reuse the resolved per-provider model levels: + +- Preserve `max` or `ultra` when the target provider/model explicitly supports + the requested level. +- Convert `ultra` to `max` for GPT-5.6 Luna, preserving the highest level Luna + supports. +- Convert other unsupported `max` or `ultra` requests to `xhigh`, preserving + the existing safe fallback for generic OpenAI-compatible providers. +- Leave all existing lower levels and `none` handling unchanged. + +This keeps one capability source for the dashboard and translation behavior +instead of duplicating the GPT-5.6 matrix. + +### Codex executor + +Make Codex reasoning normalization model-aware. After virtual review models +are resolved to their upstream base model, preserve a requested level when +the Codex capability resolver lists it. Continue converting unsupported +`max` or `ultra` values to `xhigh`, except that Luna converts `ultra` to its +supported `max` level. + +Do not add `max` to the executor's legacy hyphen-suffix parser because +`gpt-5.1-codex-max` is an actual model identifier. Dashboard overrides use the +existing parenthesized suffix and the shared translator removes that suffix +before executor dispatch. + +## Error and Compatibility Behavior + +- `cx/gpt-5.6-luna(ultra)` becomes `max` rather than sending an unsupported + level upstream. +- Non-GPT-5.6 Codex models retain their current supported levels and fallback + behavior. +- Kiro GPT-5.6 routes no longer inherit the Codex Sol-only picker override and + continue using Kiro's existing effort normalization. +- Direct request fields and parenthesized model overrides follow the same + model-aware rules. + +## Testing + +Use test-driven development with focused unit coverage: + +1. Level resolver tests for Sol, Terra, Luna, their review variants, an older + Codex model, and Kiro isolation. +2. Shared translator tests proving `max` and `ultra` survive only for supported + Codex model/provider combinations, Luna `ultra` becomes `max`, and other + unsupported combinations become `xhigh`. +3. Codex executor tests proving native and translated request shapes preserve + supported values after upstream model resolution. +4. Existing thinking translation and Codex executor suites to guard generic + OpenAI clamping and fast-tier behavior. +5. Project lint/build checks in proportion to the changed JavaScript modules. + +## Non-goals + +- Runtime fetching or caching of the Codex model catalog. +- Adding these levels to Kiro or another provider. +- Changing model pricing, quotas, defaults, or service tiers. +- Adding Codex Ultra's multi-agent orchestration behavior inside 9router; + 9router only forwards the catalog-advertised reasoning override. diff --git a/i18n/README.es.md b/i18n/README.es.md new file mode 100644 index 00000000..5469fcb7 --- /dev/null +++ b/i18n/README.es.md @@ -0,0 +1,1445 @@ +
+ Panel de control de 9Router + + # 9Router - Enrutador de IA GRATUITO y ahorrador de tokens + + **Nunca dejes de programar. Ahorra entre 20-40% de tokens con RTK + reserva automática hacia modelos de IA GRATUITOS y económicos.** + + **Conecta todas tus herramientas de código con IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) a más de 40 proveedores de IA y más de 100 modelos.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Inicio rápido](#-inicio-rápido) • [💡 Características](#-características-principales) • [📖 Configuración](#-guía-de-instalación) • [🌐 Sitio web](https://9router.com) + +[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) • [🇪🇸 Español](./README.es.md) • [🇫🇷 Français](./README.fr.md) + +
+ +--- + +## 🤔 ¿Por qué 9Router? + +**Deja de perder dinero, tokens y de chocar contra los límites:** + +- ❌ La cuota de la suscripción caduca sin usar cada mes +- ❌ Los límites de velocidad te interrumpen a mitad de la programación +- ❌ Las salidas de las herramientas (git diff, grep, ls...) consumen tokens rápidamente +- ❌ APIs caras ($20-50/mes por proveedor) +- ❌ Cambio manual entre proveedores + +**9Router resuelve esto:** + +- ✅ **Ahorrador de tokens RTK** - Comprime automáticamente el contenido de tool_result y ahorra entre 20-40% de tokens por solicitud +- ✅ **Maximiza las suscripciones** - Realiza el seguimiento de la cuota y usa cada bit antes del restablecimiento +- ✅ **Reserva automática** - Suscripción → Económico → Gratuito, sin tiempos de inactividad +- ✅ **Multi-cuenta** - Round-robin entre cuentas de cada proveedor +- ✅ **Universal** - Funciona con Claude Code, Codex, Cursor, Cline y cualquier herramienta CLI + +--- + +## 🔄 Cómo funciona + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Inicio rápido + +**1. Instálalo globalmente:** + +```bash +npm install -g 9router +9router +``` + +🎉 El panel de control se abre en `http://localhost:20128` + +**2. Conecta un proveedor GRATUITO (no requiere registro):** + +Panel de control → Providers → Conecta **Kiro AI** (Claude gratuito e ilimitado) o **OpenCode Free** (sin autenticación) → ¡Listo! + +**3. Úsalo en tu herramienta CLI:** + +``` +Ajustes de Claude Code/Codex/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [copia desde el panel de control] + Model: kr/claude-sonnet-4.5 +``` + +**¡Eso es todo!** Empieza a programar con modelos de IA GRATUITOS. + +**Alternativa: ejecutar desde el código fuente (este repositorio):** + +El paquete de este repositorio es privado (`9router-app`), por lo que ejecutar desde el código fuente/Docker es la ruta de desarrollo local prevista. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Modo de producción: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URLs por defecto: + +- Panel de control: `http://localhost:20128/dashboard` +- API compatible con OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Guías en video + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **¿Has hecho un video sobre 9Router?** Envía una [Pull Request](https://github.com/decolua/9router/pulls) añadiendo tu video a esta sección — ¡lo fusionaremos! + +--- + +## 🛠️ Herramientas CLI compatibles + +9Router funciona a la perfección con todas las principales herramientas de código con IA: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Proveedores compatibles + +### 🔐 Proveedores OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Proveedores gratuitos + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
GRATUITO e ilimitado
+
+ OpenCode Free
+ OpenCode Free
+ Sin autenticación • Modelos automáticos
GRATUITO e ilimitado
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 de crédito gratuito
+
+
+ +> **Nota:** Los niveles gratuitos de iFlow, Qwen y Gemini CLI se suspendieron en 2026. Usa Kiro / OpenCode Free / Vertex en su lugar. + +### 🔑 Proveedores con clave API (más de 40) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...y más de 20 proveedores adicionales, incluyendo Nebius, Chutes, Hyperbolic y endpoints personalizados compatibles con OpenAI/Anthropic

+
+ +--- + +## 💡 Características principales + +| Característica | Qué hace | Por qué importa | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| 🚀 **Ahorrador de tokens RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Comprime las salidas de las herramientas (`git diff`, `grep`, `ls`, `tree`...) antes de enviarlas al LLM | Ahorra **20-40% de tokens de entrada** por solicitud | +| 🧠 **Ahorrador de tokens Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy externo opcional `/v1/compress` antes del enrutamiento al proveedor | Ahorra más tokens de contexto sin cambiar los clientes | +| 🪨 **Modo cavernícola** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inyecta un prompt de lenguaje cavernícola → el LLM responde de forma concisa, se conserva el contenido técnico | Ahorra **hasta un 65% de tokens de salida** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inyecta un prompt de "dev sénior perezoso" → el LLM escribe código mínimo, primero YAGNI (Lite/Full/Ultra) | **Menos tokens de salida, menos refactorización** | +| 🎯 **Reserva inteligente de 3 niveles** | Enrutado automático: Suscripción → Económico → Gratuito | Nunca dejes de programar, cero tiempos de inactividad | +| 📊 **Seguimiento de cuota en tiempo real** | Recuento de tokens en vivo + cuenta atrás de restablecimiento | Maximiza el valor de tu suscripción | +| 🔄 **Traducción de formatos** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Funciona con cualquier herramienta CLI | +| 👥 **Compatibilidad con varias cuentas** | Varias cuentas por proveedor | Balance de carga + redundancia | +| 🔄 **Renovación automática de tokens** | Los tokens OAuth se renuevan automáticamente | Sin necesidad de volver a iniciar sesión | +| 🎨 **Combos personalizados** | Crea combinaciones de modelos ilimitadas | Adapta la reserva a tus necesidades | +| 📝 **Registro de solicitudes** | Modo de depuración con registros completos de solicitudes/respuestas | Soluciona problemas fácilmente | +| 💾 **Sincronización en la nube** | Sincroniza la configuración entre dispositivos | La misma configuración en todas partes | +| 📊 **Analítica de uso** | Realiza el seguimiento de tokens, costes y tendencias a lo largo del tiempo | Optimiza el gasto | +| 🌐 **Implementa en cualquier lugar** | Localhost, VPS, Docker, Cloudflare Workers | Opciones de implementación flexibles | + +
+📖 Detalles de las características + +### 🚀 Ahorrador de tokens RTK + +Las salidas de las herramientas (`git diff`, `grep`, `find`, `ls`, `tree`, volcados de registros...) suelen consumir entre 30-50% del presupuesto de tu prompt. RTK las detecta y aplica una compresión inteligente y sin pérdidas **antes** de que la solicitud llegue al LLM: + +- **Filtros:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Detección automática:** No requiere configuración — RTK inspecciona el primer 1KB de cada `tool_result` y elige el filtro correcto. +- **Seguro por diseño:** Si un filtro falla, lanza un error o hace la salida más grande, RTK conserva silenciosamente el texto original. Los errores nunca interrumpen tu solicitud. +- **Universal:** Funciona en todos los formatos (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) porque se ejecuta **antes** de cualquier traducción de formato. +- **Activado por defecto:** Actívalo o desactívalo en cualquier momento en Panel de control → Ajustes de Endpoint. + +``` +Sin RTK: se envían 47K tokens al LLM +Con RTK: se envían 28K tokens al LLM (40% ahorrado · mismo contexto · misma respuesta) +``` + +### 🧠 Ahorrador de tokens Headroom + +Headroom es opcional y se ejecuta por separado. 9Router llama al endpoint local `/v1/compress` de Headroom y luego mantiene el enrutamiento, la reserva, la autenticación y el seguimiento de uso normales: + +``` +Client → 9Router → Headroom /v1/compress → 9Router → provider +``` + +Configuración local: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Actívalo en Panel de control → Endpoint → Token Saver → Headroom. URL por defecto: `http://localhost:8787`. + +Ejemplos con Docker: + +```bash +# Servicio de Headroom en la misma red de Docker +http://headroom:8787 + +# Headroom ejecutándose en la máquina host +http://host.docker.internal:8787 +``` + +Si Headroom está caído o devuelve un error, 9Router falla de forma segura y envía la solicitud original. + +### 🐴 Ponytail (Dev sénior perezoso) + +Ponytail inyecta un prompt de sistema de _"dev sénior perezoso"_ en cada solicitud, orientando al LLM hacia un código mínimo y primero YAGNI — eliminación antes que adición, librería estándar antes que nuevas dependencias, una línea antes que abstracciones. Adaptado de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construye lo que se pide, nombra la alternativa más perezosa. +- **Full** — Jerarquía YAGNI aplicada: stdlib → nativo → dependencias existentes → una línea → código mínimo. +- **Ultra** — Extremista del YAGNI: eliminación primero, entrega la solución de una línea, cuestiona el resto del requisito en la misma respuesta. + +``` +Sin Ponytail: código verboso, abstracciones extra, andamiaje "por si acaso" +Con Ponytail: diff funcional más corto, sin abstracciones no solicitadas, menos tokens +``` + +Nunca sacrifica: validación de entrada, manejo de errores que evita pérdida de datos, seguridad, accesibilidad ni nada solicitado explícitamente. Actívalo en Panel de control → Endpoint → Ponytail. Se combina con Caveman (concisión de salida) y RTK (compresión de entrada). + +### 🎯 Reserva inteligente de 3 niveles + +Crea combos con reserva automática: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (tu suscripción) + 2. glm/glm-4.7 (respaldo económico, $0.6/1M) + 3. if/kimi-k2-thinking (reserva gratuita) + +→ Cambia automáticamente cuando la cuota se agota o se producen errores +``` + +### 📊 Seguimiento de cuota en tiempo real + +- Consumo de tokens por proveedor +- Cuenta atrás de restablecimiento (5 horas, diaria, semanal) +- Estimación de costes para niveles de pago +- Informes de gasto mensual + +### 🔄 Traducción de formatos + +Traducción fluida entre formatos: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Tu herramienta CLI envía el formato OpenAI → 9Router lo traduce → El proveedor recibe el formato nativo +- Funciona con cualquier herramienta que admita endpoints OpenAI personalizados + +### 👥 Compatibilidad con varias cuentas + +- Añade varias cuentas por proveedor +- Enrutado automático round-robin o basado en prioridades +- Reserva a la siguiente cuenta cuando una alcanza su cuota + +### 🔄 Renovación automática de tokens + +- Los tokens OAuth se renuevan automáticamente antes de caducar +- Sin necesidad de reautenticación manual +- Experiencia fluida en todos los proveedores + +### 🎨 Combos personalizados + +- Crea combinaciones de modelos ilimitadas +- Mezcla niveles de suscripción, económicos y gratuitos +- Pon nombre a tus combos para acceder fácilmente +- Comparte combos entre dispositivos con Sincronización en la nube + +### 📝 Registro de solicitudes + +- Activa el modo de depuración para registros completos de solicitudes/respuestas +- Realiza el seguimiento de llamadas API, cabeceras y cargas útiles +- Soluciona problemas de integración +- Exporta los registros para su análisis + +### 💾 Sincronización en la nube + +- Sincroniza proveedores, combos y ajustes entre dispositivos +- Sincronización automática en segundo plano +- Almacenamiento cifrado y seguro +- Accede a tu configuración desde cualquier lugar + +#### Notas del runtime en la nube + +- Prefiere las variables de nube del lado del servidor en producción: + - `BASE_URL` (URL de retorno interno usada por el planificador de sincronización) + - `CLOUD_URL` (base del endpoint de sincronización en la nube) +- `NEXT_PUBLIC_BASE_URL` y `NEXT_PUBLIC_CLOUD_URL` siguen siendo compatibles para compatibilidad/UI, pero el runtime del servidor ahora prioriza `BASE_URL`/`CLOUD_URL`. +- Las solicitudes de sincronización en la nube ahora usan tiempo de espera + comportamiento de fallo rápido para evitar que la UI se cuelgue cuando el DNS/la red de la nube no está disponible. + +### 📊 Analítica de uso + +- Realiza el seguimiento del uso de tokens por proveedor y modelo +- Estimación de costes y tendencias de gasto +- Informes e información mensual +- Optimiza tu gasto en IA + +> **💡 IMPORTANTE - Comprender los costes del panel de control:** +> +> El "coste" que se muestra en Analítica de uso es **solo para seguimiento y comparación**. +> 9Router por sí mismo **nunca te cobra** nada. Solo pagas a los proveedores directamente (si usas servicios de pago). +> +> **Ejemplo:** Si tu panel muestra "coste total de $290" mientras usas modelos iFlow, esto representa +> lo que habrías pagado usando APIs de pago directamente. Tu coste real = **$0** (iFlow es gratis e ilimitado). +> +> ¡Piénsalo como un "rastreador de ahorros" que muestra cuánto estás ahorrando al usar modelos gratuitos o +> enrutar a través de 9Router! + +### 🌐 Implementa en cualquier lugar + +- 💻 **Localhost** - Por defecto, funciona sin conexión +- ☁️ **VPS/Nube** - Comparte entre dispositivos +- 🐳 **Docker** - Implementación con un solo comando +- 🚀 **Cloudflare Workers** - Red perimetral global + +
+ +--- + +## 💰 Precios de un vistazo + +| Nivel | Proveedor | Coste | Restablecimiento de cuota | Mejor para | +| --------------------- | ---------------------- | -------------- | ------------------------- | ------------------------------------------- | +| **🚀 AHORRO DE TOKENS** | **RTK (integrado)** | **GRATUITO** | Siempre activo | **Ahorra 20-40% de tokens en CADA solicitud** | +| **💳 SUSCRIPCIÓN** | Claude Code (Pro/Max) | $20-200/mes | 5 h + semanal | Ya suscrito | +| | Codex (Plus/Pro) | $20-200/mes | 5 h + semanal | Usuarios de OpenAI | +| | GitHub Copilot | $10-19/mes | Mensual | Usuarios de GitHub | +| | Cursor IDE | $20/mes | Mensual | Usuarios de Cursor | +| **💰 ECONÓMICO** | GLM-5.1 / GLM-4.7 | $0.6/1M | Diario 10:00 | Respaldo económico | +| | MiniMax M2.7 | $0.2/1M | Ventana de 5 horas | La opción más barata | +| | Kimi K2.5 | $9/mes fijos | 10M tokens/mes | Coste predecible | +| **🆓 GRATUITO** | Kiro AI | $0 | Ilimitado | Claude 4.5 + GLM-5 + MiniMax gratis | +| | OpenCode Free | $0 | Ilimitado | Sin autenticación, modelos automáticos | +| | Vertex AI | $300 de crédito | Cuentas GCP nuevas | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Consejo profesional:** Combo RTK + Kiro AI + OpenCode Free = **$0 de coste + 20-40% de ahorro de tokens**! + +--- + +### 📊 Cómo entender los costes y la facturación de 9Router + +**Realidad de la facturación de 9Router:** + +✅ **El software de 9Router = GRATUITO para siempre** (código abierto, nunca cobra) +✅ **Los "costes" del panel = Solo visualización/seguimiento** (no son facturas reales) +✅ **Pagas a los proveedores directamente** (suscripciones o tarifas de API) +✅ **Los proveedores GRATUITOS siguen siendo GRATUITOS** (iFlow, Kiro, Qwen = $0 ilimitado) +❌ **9Router nunca envía facturas** ni carga tu tarjeta + +**Cómo funciona la visualización de costes:** + +El panel muestra **costes estimados** como si estuvieras usando APIs de pago directamente. Esto **no es facturación** — es una herramienta de comparación para mostrar tus ahorros. + +**Escenario de ejemplo:** + +``` +Visualización del panel: +• Total de solicitudes: 1,662 +• Tokens totales: 47M +• Coste mostrado: $290 + +Comprobación de la realidad: +• Proveedor: iFlow (GRATUITO e ilimitado) +• Pago real: $0.00 +• Qué significa $290: ¡La cantidad que AHORRASTE usando modelos gratuitos! +``` + +**Reglas de pago:** + +- **Proveedores de suscripción** (Claude Code, Codex): Págales directamente a través de sus sitios web +- **Proveedores económicos** (GLM, MiniMax): Págales directamente, 9Router solo enruta +- **Proveedores GRATUITOS** (iFlow, Kiro, Qwen): Realmente gratuitos para siempre, sin cargos ocultos +- **9Router**: Nunca cobra nada, jamás + +--- + +## 🎯 Casos de uso + +### Caso 1: "Tengo una suscripción a Claude Pro" + +**Problema:** La cuota caduca sin usar, límites de velocidad durante programación intensa + +**Solución:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (usa la suscripción al máximo) + 2. glm/glm-5.1 (respaldo económico cuando se agota la cuota) + 3. kr/claude-sonnet-4.5 (reserva gratuita de emergencia) + +Coste mensual: $20 (suscripción) + ~$5 (respaldo) = $25 en total +vs. $20 + chocar contra límites = frustración +``` + +### Caso 2: "Quiero coste cero" + +**Problema:** No puedo permitirme suscripciones, necesito programación con IA fiable + +**Solución:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito e ilimitado) + 2. kr/glm-5 (GLM-5 gratis a través de Kiro) + 3. oc/ (OpenCode Free, sin autenticación) + +Coste mensual: $0 +Calidad: Modelos listos para producción + RTK ahorra 20-40% de tokens +``` + +### Caso 3: "Necesito programar 24/7, sin interrupciones" + +**Problema:** Plazos de entrega, no puedo permitirme tiempos de inactividad + +**Solución:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (la mejor calidad) + 2. cx/gpt-5.5 (segunda suscripción) + 3. glm/glm-5.1 (económico, se restablece diariamente) + 4. minimax/MiniMax-M2.7 (el más barato, restablecimiento cada 5 h) + 5. kr/claude-sonnet-4.5 (gratuito e ilimitado) + +Resultado: 5 capas de reserva = cero tiempos de inactividad +Coste mensual: $20-200 (suscripciones) + $10-20 (respaldo) +``` + +### Caso 4: "Quiero IA GRATUITA en OpenClaw" + +**Problema:** Necesito un asistente de IA en aplicaciones de mensajería (WhatsApp, Telegram, Slack...), completamente gratis + +**Solución:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito) + 2. kr/glm-5 (GLM-5 gratuito) + 3. kr/MiniMax-M2.5 (MiniMax gratuito) + +Coste mensual: $0 +Acceso mediante: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Preguntas frecuentes + +
+📊 ¿Por qué mi panel muestra costes elevados? + +El panel realiza el seguimiento de tu uso de tokens y muestra **costes estimados** como si estuvieras usando APIs de pago directamente. Esto **no es facturación real** — es una referencia para mostrar cuánto estás ahorrando al usar modelos gratuitos o suscripciones existentes a través de 9Router. + +**Ejemplo:** + +- **El panel muestra:** "$290 de coste total" +- **La realidad:** Estás usando iFlow (GRATUITO e ilimitado) +- **Tu coste real:** **$0.00** +- **Qué significa $290:** ¡La cantidad que **ahorraste** al usar modelos gratuitos en lugar de APIs de pago! + +La visualización de costes es un "rastreador de ahorros" que te ayuda a comprender tus patrones de uso y las oportunidades de optimización. + +
+ +
+💳 ¿9Router me cobrará algo? + +**No.** 9Router es un software gratuito y de código abierto que se ejecuta en tu propio ordenador. Nunca te cobra nada. + +**Solo pagas:** + +- ✅ **Proveedores de suscripción** (Claude Code $20/mes, Codex $20-200/mes) → Págales directamente en sus sitios web +- ✅ **Proveedores económicos** (GLM, MiniMax) → Págales directamente, 9Router solo enruta tus solicitudes +- ❌ **El propio 9Router** → **Nunca cobra nada, jamás** + +9Router es un proxy/enrutador local. No tiene tu tarjeta de crédito, no puede enviar facturas y no tiene sistema de facturación. Es software totalmente gratuito. + +
+ +
+🆓 ¿Los proveedores GRATUITOS son realmente ilimitados? + +**¡Sí!** Los proveedores GRATUITOS actuales (Kiro, OpenCode Free, Vertex) son realmente gratuitos y **no tienen cargos ocultos**. + +Estos son servicios gratuitos ofrecidos por esas respectivas empresas: + +- **Kiro AI**: Claude 4.5 + GLM-5 + MiniMax gratuitos e ilimitados mediante AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: Proxy de paso sin autenticación, modelos obtenidos automáticamente de `opencode.ai/zen/v1/models` +- **Vertex AI**: $300 de crédito gratuito para cuentas nuevas de Google Cloud (90 días) + +9Router solo enruta tus solicitudes hacia ellos — no hay "trampa" ni facturación futura. Son servicios realmente gratuitos y 9Router los hace fáciles de usar con soporte de reserva. + +**Niveles gratuitos suspendidos (ya no recomendados):** + +- ❌ **iFlow**: Era gratuito e ilimitado, ahora es de pago (2026) +- ❌ **Qwen Code**: El nivel gratuito de OAuth fue suspendido por Alibaba el 15-04-2026 +- ❌ **Gemini CLI**: Sigue funcionando, pero usarlo con herramientas que no son CLI (Claude, Codex, Cursor...) puede resultar en baneo de cuentas — úsalo solo si te quedas con el propio Gemini CLI + +
+ +
+💰 ¿Cómo minimizo mis costes reales de IA? + +**Estrategia gratis-primero:** + +1. **Empieza con un combo 100% gratuito:** + + ``` + 1. gc/gemini-3-flash (180K/mes gratis de Google) + 2. if/kimi-k2-thinking (gratuito e ilimitado de iFlow) + 3. qw/qwen3-coder-plus (gratuito e ilimitado de Qwen) + ``` + + **Coste: $0/mes** + +2. **Añade un respaldo económico** solo si lo necesitas: + + ``` + 4. glm/glm-4.7 ($0.6/1M de tokens) + ``` + + **Coste adicional: Solo pagas por lo que realmente usas** + +3. **Usa los proveedores de suscripción al final:** + - Solo si ya los tienes + - 9Router te ayuda a maximizar su valor mediante el seguimiento de cuota + +**Resultado:** ¡La mayoría de los usuarios pueden operar con $0/mes usando solo niveles gratuitos! + +
+ +
+📈 ¿Qué pasa si mi uso se dispara de repente? + +La reserva inteligente de 9Router evita cargos sorpresa: + +**Escenario:** Estás en un sprint de programación y agotas tus cuotas + +**Sin 9Router:** + +- ❌ Chocas contra el límite de velocidad → El trabajo se detiene → Frustración +- ❌ O: Acumulas accidentalmente facturas enormes de API + +**Con 9Router:** + +- ✅ La suscripción alcanza su límite → Reserva automática al nivel económico +- ✅ El nivel económico se vuelve caro → Reserva automática al nivel gratuito +- ✅ Nunca dejas de programar → Costes predecibles + +**Tú tienes el control:** Establece límites de gasto por proveedor en el panel y 9Router los respeta. + +
+ +--- + +## 📖 Guía de instalación + +
+🔐 Proveedores de suscripción (maximiza el valor) + +### Claude Code (Pro/Max) + +```bash +Panel de control → Providers → Conecta Claude Code +→ Inicio de sesión OAuth → Renovación automática de tokens +→ Seguimiento de cuota de 5 horas + semanal + +Modelos: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Consejo profesional:** Usa Opus para tareas complejas y Sonnet para velocidad. ¡9Router realiza el seguimiento de la cuota por modelo! + +### OpenAI Codex (Plus/Pro) + +```bash +Panel de control → Providers → Conecta Codex +→ Inicio de sesión OAuth (puerto 1455) +→ Restablecimiento de 5 horas + semanal + +Modelos: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Panel de control → Providers → Conecta GitHub +→ OAuth a través de GitHub +→ Restablecimiento mensual (día 1 de cada mes) + +Modelos: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Panel de control → Providers → Conecta Cursor +→ Inicio de sesión OAuth +→ Suscripción mensual + +Modelos: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Proveedores económicos (respaldo) + +### GLM-5.1 / GLM-4.7 (restablecimiento diario, $0.6/1M) + +1. Regístrate: [Zhipu AI](https://open.bigmodel.cn/) +2. Obtén la clave API del Coding Plan +3. Panel de control → Add API Key: + - Proveedor: `glm` + - API Key: `tu-clave` + +**Uso:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Consejo profesional:** ¡El Coding Plan ofrece cuota 3× por 1/7 del coste! Restablecimiento diario a las 10:00. + +### MiniMax M2.7 (restablecimiento cada 5 h, $0.20/1M) + +1. Regístrate: [MiniMax](https://www.minimax.io/) +2. Obtén la clave API +3. Panel de control → Add API Key + +**Uso:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Consejo profesional:** ¡La opción más barata para contexto largo (1M de tokens)! + +### Kimi K2.5 ($9/mes fijos) + +1. Suscríbete: [Moonshot AI](https://platform.moonshot.ai/) +2. Obtén la clave API +3. Panel de control → Add API Key + +**Uso:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Consejo profesional:** ¡$9/mes fijos por 10M de tokens = coste efectivo de $0.90/1M! + +
+ +
+🆓 Proveedores GRATUITOS (recomendados) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATIS) + +```bash +Panel de control → Conecta Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google o GitHub +→ Uso ilimitado + +Modelos: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Consejo profesional:** La mejor opción gratuita para Claude. Sin clave API, sin pago, totalmente ilimitado. + +### OpenCode Free (sin autenticación, modelos automáticos) + +```bash +Panel de control → Conecta OpenCode Free +→ No requiere inicio de sesión (proxy de paso) +→ Modelos obtenidos automáticamente de opencode.ai/zen/v1/models +``` + +**Consejo profesional:** La configuración más rápida. Solo conéctate y empieza a programar. + +### Vertex AI ($300 de crédito gratuito para cuentas GCP nuevas) + +```bash +Panel de control → Conecta Vertex AI +→ Sube el JSON de la cuenta de servicio de Google Cloud +→ Habilita la API de Vertex AI en tu proyecto de GCP + +Modelos: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen a través de Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Consejo profesional:** Las cuentas nuevas de Google Cloud obtienen $300 de crédito gratis durante 90 días. Más que suficiente para programar a diario. + +
+ +
+🎨 Crea combos + +### Ejemplo 1: Maximiza la suscripción → Respaldo económico + +``` +Panel de control → Combos → Create New + +Nombre: premium-coding +Modelos: + 1. cc/claude-opus-4-7 (Suscripción principal) + 2. glm/glm-5.1 (Respaldo económico, $0.6/1M) + 3. minimax/MiniMax-M2.7 (Reserva más barata, $0.20/1M) + +Uso en CLI: premium-coding + +Ejemplo de coste mensual (100M de tokens): + 80M vía Claude (suscripción): $0 extra + 15M vía GLM: $9 + 5M vía MiniMax: $1 + Total: $10 + tu suscripción +``` + +### Ejemplo 2: Solo gratis (coste cero) + +``` +Nombre: free-combo +Modelos: + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito e ilimitado) + 2. kr/glm-5 (GLM-5 gratis a través de Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 de crédito gratuito) + +Coste: $0 para siempre (+ 20-40% de ahorro de tokens vía RTK)! +``` + +
+ +
+🔧 Integración con CLI + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [desde el panel de 9router] + Model: cc/claude-opus-4-7 +``` + +O usa el combo: `premium-coding` + +### Claude Code + +Edita `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "tu-clave-api-de-9router" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="tu-clave-api-de-9router" + +codex "tu prompt" +``` + +### OpenClaw + +**Opción 1 — Panel de control (recomendada):** + +``` +Panel de control → CLI Tools → OpenClaw → Selecciona el modelo → Aplicar +``` + +**Opción 2 — Manual:** Edita `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Nota:** OpenClaw solo funciona con 9Router local. Usa `127.0.0.1` en lugar de `localhost` para evitar problemas de resolución IPv6. + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [desde el panel] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Implementación + +### Implementación en VPS + +```bash +# Clona e instala +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configura +export JWT_SECRET="tu-secreto-seguro-cámbialo" +export INITIAL_PASSWORD="tu-contraseña" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Inicia +npm run start + +# O usa PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Imágenes publicadas (multi-plataforma `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Inicio rápido (usa la imagen publicada):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Abre http://localhost:20128 + +**Compilar desde el código fuente (dev):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Valores por defecto del contenedor:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Comandos útiles:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # actualiza a la última versión +``` + +**Persistencia de datos:** `$HOME/.9router/db/data.sqlite` en el host ↔ `/app/data/db/data.sqlite` en el contenedor. + +### Variables de entorno + +| Variable | Por defecto | Descripción | +| ---------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | +| `JWT_SECRET` | Generado automáticamente (`~/.9router/jwt-secret`) | Secreto de firma JWT para la cookie de autenticación del panel (sobreescríbelo para compartir entre instancias) | +| `INITIAL_PASSWORD` | `123456` | Contraseña del primer inicio de sesión cuando no existe un hash guardado | +| `DATA_DIR` | `~/.9router` | Ubicación principal de datos de la app (SQLite en `$DATA_DIR/db/data.sqlite`) | +| `PORT` | por defecto del framework | Puerto del servicio (`20128` en los ejemplos) | +| `HOSTNAME` | por defecto del framework | Host de enlace (Docker usa `0.0.0.0` por defecto) | +| `NODE_ENV` | por defecto del runtime | Establece `production` para el despliegue | +| `BASE_URL` | `http://localhost:20128` | URL base interna del lado del servidor usada por los trabajos de sincronización en la nube | +| `CLOUD_URL` | `https://9router.com` | URL base del endpoint de sincronización en la nube del lado del servidor | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL base pública/compatible con versiones anteriores (prefiere `BASE_URL` para el runtime del servidor) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL de nube pública/compatible (prefiere `CLOUD_URL` para el runtime del servidor) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Secreto HMAC para las claves API generadas | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Sal para el hash estable del ID de máquina | +| `ENABLE_REQUEST_LOGS` | `false` | Habilita los registros de solicitudes/respuestas en `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Fuerza la cookie `Secure` de autenticación (establece `true` detrás de un proxy HTTPS inverso) | +| `REQUIRE_API_KEY` | `false` | Aplica clave API Bearer en las rutas `/v1/*` (recomendado para despliegues expuestos a Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vacío | Proxy saliente opcional para las llamadas a proveedores externos | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint del proveedor de búsqueda web SearXNG integrado sin autenticación | + +Notas: + +- También se admiten variables de proxy en minúsculas: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` no se incluye en la imagen de Docker (`.dockerignore`); inyecta la configuración del runtime con `--env-file` o `-e`. +- En Windows, se puede usar `APPDATA` para resolver la ruta de almacenamiento local. +- `INSTANCE_NAME` aparece en documentación/plantillas de entorno antiguas, pero actualmente no se usa en el runtime. + +### Archivos en tiempo de ejecución y almacenamiento + +- Estado principal de la app: `${DATA_DIR}/db/data.sqlite` (SQLite — proveedores, combos, alias, claves, ajustes, historial de uso) +- Copias de seguridad automáticas: `${DATA_DIR}/db/backups/` +- Registros opcionales de solicitudes/translator: `/logs/...` cuando `ENABLE_REQUEST_LOGS=true` +- Tanto `${DATA_DIR}` como `~/.9router` resuelven a la misma ubicación en un contenedor de Docker — el enlace simbólico `/root/.9router -> /app/data` se crea en tiempo de compilación. + +
+ +--- + +## 📊 Modelos disponibles + +
+Ver todos los modelos disponibles + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Suscripción: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mes fijos: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - GRATUITO e ilimitado: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - GRATUITO sin autenticación: + +- Obtenidos automáticamente de `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - $300 de crédito gratuito: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Solución de problemas + +**"Language model did not provide messages"** + +- Cuota del proveedor agotada → Revisa el rastreador de cuota del panel +- Solución: usa la reserva del combo o cambia a un nivel más barato + +**Límites de velocidad** + +- Cuota de suscripción agotada → Reserva a GLM/MiniMax +- Añade el combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**Token OAuth caducado** + +- 9Router lo renueva automáticamente +- Si los problemas persisten: Panel de control → Provider → Reconectar + +**Costes elevados** + +- Habilita RTK en Panel de control → Ajustes de Endpoint (activado por defecto, ahorra 20-40% de tokens) +- Revisa las estadísticas de uso en el panel +- Cambia el modelo principal a GLM/MiniMax +- Usa el nivel gratuito (Kiro, OpenCode Free, Vertex) para tareas no críticas + +**El panel se abre en el puerto equivocado** + +- Establece `PORT=20128` y `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**El primer inicio de sesión no funciona** + +- Revisa `INITIAL_PASSWORD` en `.env` +- Si no está configurada, la contraseña de respaldo es `123456` + +**No hay registros de solicitudes en `logs/`** + +- Establece `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pila tecnológica + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Base de datos**: SQLite (better-sqlite3 / node:sqlite / sql.js como respaldo) +- **Streaming**: Server-Sent Events (SSE) +- **Autenticación**: OAuth 2.0 (PKCE) + JWT + claves API + +--- + +## 📝 Referencia de la API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer tu-clave-api +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Escribe una función para..."} + ], + "stream": true +} +``` + +### Listar modelos + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer tu-clave-api + +→ Devuelve todos los modelos y combos en formato OpenAI +``` + +## 📧 Soporte + +- **Sitio web**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Problemas**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Colaboradores + +¡Gracias a todos los colaboradores que ayudaron a mejorar 9Router! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Gráfico de estrellas + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Un fork de 9Router en TypeScript con todas las funciones. Añade más de 36 proveedores, reserva automática de 4 niveles, APIs multimodales (imágenes, embeddings, audio, TTS), interruptor de circuitos, caché semántica, evaluaciones de LLM y un panel pulido. Más de 368 pruebas unitarias. Disponible a través de npm y Docker. + +--- + +## 🙏 Agradecimientos + +Construido sobre los hombros de gigantes: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implementación original en Go que inspiró este port a JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — ahorrador de tokens en Rust. 9Router porta su pipeline de compresión a JS → **−20-40% de tokens de entrada** en cada solicitud. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) por **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapta su prompt → **−65% de tokens de salida**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) por **[@DietrichGebert](https://github.com/DietrichGebert)** — habilidad del _"dev sénior perezoso"_. 9Router inyecta su jerarquía primero-YAGNI → **menos tokens, menos código, diffs más cortos**. + +Un enorme agradecimiento a estos autores — sin su trabajo, las funciones de ahorro de tokens de 9Router no existirían. ¡Dales una ⭐ en GitHub! + +--- + +## 📄 Licencia + +Licencia MIT - consulta [LICENSE](LICENSE) para más detalles. + +--- + +
+ Hecho con ❤️ para desarrolladores que programan 24/7 +
+ diff --git a/i18n/README.fa_IR.md b/i18n/README.fa_IR.md new file mode 100644 index 00000000..e486fa61 --- /dev/null +++ b/i18n/README.fa_IR.md @@ -0,0 +1,1442 @@ +
+ داشبورد 9Router + + # 9Router - مسیریاب رایگان هوش مصنوعی و ذخیره‌ساز توکن + + **هرگز کدنویسی را متوقف نکنید. با RTK بین ۲۰ تا ۴۰٪ در توکن‌ها صرفه‌جویی کنید + بازگشت خودکار به مدل‌های رایگان و ارزان هوش مصنوعی.** + + **همه ابزارهای کدنویسی مبتنی بر هوش مصنوعی (Claude Code، Cursor، Antigravity، Copilot، Codex، Gemini، OpenCode، Cline، OpenClaw...) را به بیش از ۴۰ ارائه‌دهنده و ۱۰۰+ مدل متصل کنید.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 شروع سریع](#-شروع-سریع) • [💡 ویژگی‌ها](#-ویژگی‌های-کلیدی) • [📖 راه‌اندازی](#-راهنمای-راه‌اندازی) • [🌐 وب‌سایت](https://9router.com) + +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) + +
+ +--- + +## 🤔 چرا 9Router؟ + +**هدررفت پول، توکن و برخورد با محدودیت‌ها را متوقف کنید:** + +- ❌ سهمیه اشتراک هر ماه بدون استفاده منقضی می‌شود +- ❌ محدودیت نرخ درخواست، شما را در میانه کدنویسی متوقف می‌کند +- ❌ خروجی ابزارها (git diff، grep، ls...) به سرعت توکن می‌سوزانند +- ❌ APIهای گران قیمت (۲۰ تا ۵۰ دلار در ماه برای هر ارائه‌دهنده) +- ❌ جابجایی دستی بین ارائه‌دهندگان + +**9Router این مشکلات را حل می‌کند:** + +- ✅ **ذخیره‌ساز توکن RTK** - فشرده‌سازی خودکار محتوای tool_result، صرفه‌جویی ۲۰ تا ۴۰٪ توکن در هر درخواست +- ✅ **حداکثر استفاده از اشتراک‌ها** - پیگیری سهمیه، استفاده از هر ذره قبل از بازنشانی +- ✅ **بازگشت خودکار** - اشتراک → ارزان → رایگان، بدون توقف +- ✅ **چند حساب کاربری** - چرخش گردشی بین حساب‌ها برای هر ارائه‌دهنده +- ✅ **جهانی** - با Claude Code، Codex، Cursor، Cline و هر ابزار خط فرمان کار می‌کند + +--- + +## 🔄 نحوه عملکرد + +``` +┌─────────────┐ +│ ابزار خط │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ فرمان شما │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (مسیریاب هوشمند) │ +│ • ذخیره‌ساز توکن RTK (کاهش توکن‌های tool_result) │ +│ • ترجمه قالب (OpenAI ↔ Claude) │ +│ • پیگیری سهمیه │ +│ • بازسازی خودکار توکن │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [لایه ۱: اشتراک] Claude Code, Codex, GitHub Copilot + │ ↓ اتمام سهمیه + ├─→ [لایه ۲: ارزان] GLM (۰.۶ دلار/میلیون), MiniMax (۰.۲ دلار/میلیون) + │ ↓ محدودیت بودجه + └─→ [لایه ۳: رایگان] Kiro, OpenCode Free, Vertex (۳۰۰ دلار اعتبار) + +نتیجه: هرگز کدنویسی را متوقف نکنید، حداقل هزینه + صرفه‌جویی ۲۰-۴۰٪ توکن با RTK +``` + +--- + +## ⚡ شروع سریع + +**۱. نصب سراسری:** + +```bash +npm install -g 9router +9router +``` + +🎉 داشبورد در آدرس `http://localhost:20128` باز می‌شود + +**۲. اتصال یک ارائه‌دهنده رایگان (بدون نیاز به ثبت‌نام):** + +داشبورد → ارائه‌دهندگان → اتصال **Kiro AI** (کلود رایگان نامحدود) یا **OpenCode Free** (بدون احراز هویت) → انجام شد! + +**۳. استفاده در ابزار خط فرمان خود:** + +``` +تنظیمات Claude Code/Codex/OpenClaw/Cursor/Cline: + آدرس端点: http://localhost:20128/v1 + کلید API: [کپی از داشبورد] + مدل: kr/claude-sonnet-4.5 +``` + +**کار تمام!** با مدل‌های رایگان هوش مصنوعی کدنویسی را شروع کنید. + +**روش جایگزین: اجرا از سورس (این مخزن):** + +بسته این مخزن خصوصی است (`9router-app`)، بنابراین اجرا از سورس/داکر مسیر معمول توسعه محلی است. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +حالت تولید: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +آدرس‌های پیش‌فرض: + +- داشبورد: `http://localhost:20128/dashboard` +- API سازگار با OpenAI: `http://localhost:20128/v1` + +--- + +## راهنماهای تصویری + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + صرفه‌جویی در هزینه LLM با 9Router +
+ 🇻🇳 ویتنامی
+ صرفه‌جویی در هزینه LLM برای OpenClaw با 9Router
توسط Mì AI
+
+ + راه‌اندازی نامحدود رایگان 9Router + Claude Code +
+ 🇵🇰 اردو / हिन्दी
+ راه‌اندازی نامحدود رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + آموزش راه‌اندازی 9Router +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + آموزش راه‌اندازی 9Router +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + Claude Code رایگان برای همیشه +
+ 🇺🇸 انگلیسی
+ Claude Code رایگان برای همیشه — مدل‌های نامحدود
توسط Build AI With Hamid
+
+ + راه‌اندازی رایگان Claude CLI +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان Claude CLI با 9Router 🚀
توسط CodeVerse Soban
+
+ + نصب کامل OpenClaw رایگان +
+ 🇻🇳 ویتنامی
+ نصب کامل OpenClaw رایگان از صفر تا صد + 9Router
توسط Mai Gia
+
+ + OpenClaw رایگان با Claude Opus +
+ 🇺🇸 انگلیسی
+ OpenClaw رایگان + Claude Opus 4.6
توسط Build AI With Hamid
+
+ + راه‌اندازی رایگان Claude CLI +
+ 🇮🇩 اندونزیایی
+ کدنویسی ۲۴ ساعته بدون محدودیت نرخ! صرفه‌جویی ۶۵٪ توکن هوش مصنوعی | آموزش راه‌اندازی سریع 9Router 🚀
توسط Krisswuh
+
+ + روش استقرار 9Router در Hugging Face رایگان و همیشه روشن! | جایگزین VPS با ۱۶ گیگابایت رم +
+ 🇮🇩 اندونزیایی
+ روش استقرار 9Router در Hugging Face رایگان و همیشه روشن! | جایگزین VPS با ۱۶ گیگابایت رم
توسط Krisswuh
+
+ +
+ +> 🎬 **درباره 9Router ویدیو ساخته‌اید؟** یک [درخواست Pull](https://github.com/decolua/9router/pulls) برای افزودن ویدیوی خود به این بخش ارسال کنید — ما آن را ادغام خواهیم کرد! + +--- + +## 🛠️ ابزارهای خط فرمان پشتیبانی شده + +9Router به‌طور یکپارچه با تمام ابزارهای اصلی کدنویسی هوش مصنوعی کار می‌کند: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 ارائه‌دهندگان پشتیبانی شده + +### 🔐 ارائه‌دهندگان OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 ارائه‌دهندگان رایگان + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
نامحدود رایگان
+
+ OpenCode Free
+ OpenCode Free
+ بدون احراز هویت • دریافت خودکار مدل‌ها
نامحدود رایگان
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
۳۰۰ دلار اعتبار رایگان
+
+
+ +> **توجه:** لایه‌های رایگان iFlow، Qwen و Gemini CLI در سال ۲۰۲۶ متوقف شدند. به جای آنها از Kiro / OpenCode Free / Vertex استفاده کنید. + +### 🔑 ارائه‌دهندگان کلید API (۴۰+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...و بیش از ۲۰ ارائه‌دهنده دیگر از جمله Nebius، Chutes، Hyperbolic و نقاط پایانی سفارشی سازگار با OpenAI/Anthropic

+
+ +--- + +## 💡 ویژگی‌های کلیدی + +| ویژگی | عملکرد | اهمیت آن | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **ذخیره‌ساز توکن RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐۴۰هزار) | فشرده‌سازی خروجی ابزارها (`git diff`، `grep`، `ls`، `tree`...) قبل از ارسال به LLM | صرفه‌جویی **۲۰ تا ۴۰٪ توکن ورودی** در هر درخواست | +| 🧠 **ذخیره‌ساز توکن Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | پروکسی خارجی اختیاری `/v1/compress` قبل از مسیریابی به ارائه‌دهنده | صرفه‌جویی توکن‌های زمینه بیشتر بدون تغییر کلاینت | +| 🪨 **حالت غارنشین** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐۵۲هزار) | تزریق پرامپت حالت غارنشین → پاسخ‌های مختصر LLM با حفظ محتوای فنی | صرفه‌جویی **تا ۶۵٪ توکن خروجی** | +| 🐴 **دم‌اسب** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | تزریق پرامپت "توسعه‌دهنده ارشد تنبل" → کدنویسی حداقلی و YAGNI-first (سبک/کامل/فوق‌سبک) | **توکن خروجی کمتر، بازنویسی کمتر** | +| 🎯 **بازگشت هوشمند ۳ لایه** | مسیریابی خودکار: اشتراک → ارزان → رایگان | هرگز کدنویسی متوقف نمی‌شود، بدون توقف | +| 📊 **پیگیری سهمیه به‌روز** | تعداد توکن زنده + شمارش معکوس بازنشانی | حداکثر استفاده از اشتراک | +| 🔄 **ترجمه قالب** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | کار با هر ابزار خط فرمان | +| 👥 **پشتیبانی از چند حساب** | چند حساب برای هر ارائه‌دهنده | توزیع بار + افزونگی | +| 🔄 **بازسازی خودکار توکن** | توکن‌های OAuth به‌طور خودکار بازسازی می‌شوند | بدون نیاز به ورود مجدد دستی | +| 🎨 **ترکیب‌های سفارشی** | ایجاد ترکیب‌های نامحدود مدل | تنظیم بازگشت بر اساس نیاز شما | +| 📝 **ثبت درخواست** | حالت اشکال‌زدایی با لاگ‌های کامل درخواست/پاسخ | عیب‌یابی آسان مسائل | +| 💾 **همگام‌سازی ابری** | همگام‌سازی تنظیمات بین دستگاه‌ها | همان تنظیمات در همه جا | +| 📊 **تحلیل استفاده** | پیگیری توکن‌ها، هزینه، روندها در طول زمان | بهینه‌سازی هزینه‌ها | +| 🌐 **استقرار در هر جا** | لوکال‌هست، VPS، داکر، Cloudflare Workers | گزینه‌های استقرار انعطاف‌پذیر | + +
+📖 جزئیات ویژگی‌ها + +### 🚀 ذخیره‌ساز توکن RTK + +خروجی ابزارها (`git diff`، `grep`، `find`، `ls`، `tree`، دامپ لاگ‌ها...) اغلب ۳۰ تا ۵۰٪ از بودجه پرامپت شما را مصرف می‌کنند. RTK آنها را شناسایی کرده و فشرده‌سازی هوشمند و بدون افت کیفیت **قبل از رسیدن درخواست به LLM** اعمال می‌کند: + +- **فیلترها:** `git-diff`، `git-status`، `grep`، `find`، `ls`، `tree`، `dedup-log`، `smart-truncate`، `read-numbered`، `search-list` +- **تشخیص خودکار:** نیازی به تنظیمات نیست — RTK یک کیلوبایت اول هر `tool_result` را بررسی کرده و فیلتر مناسب را انتخاب می‌کند. +- **ایمن در طراحی:** اگر فیلتری با شکست مواجه شود، خطا دهد یا خروجی را بزرگ‌تر کند، RTK بی‌صدا متن اصلی را نگه می‌دارد. خطاها هرگز درخواست شما را خراب نمی‌کنند. +- **جهانی:** در همه فرمت‌ها (OpenAI، Claude، Gemini، Cursor، Kiro، OpenAI Responses) کار می‌کند زیرا **قبل از** هرگونه ترجمه قالب اجرا می‌شود. +- **روشن پیش‌فرض:** در هر زمان در داشبورد → تنظیمات نقطه پایانی قابل تغییر است. + +``` +بدون RTK: ۴۷ هزار توکن ارسال شده به LLM +با RTK: ۲۸ هزار توکن ارسال شده به LLM (۴۰٪ صرفه‌جویی · همان زمینه · همان پاسخ) +``` + +### 🧠 ذخیره‌ساز توکن Headroom + +Headroom اختیاری است و به‌طور جداگانه اجرا می‌شود. 9Router نقطه پایانی محلی `/v1/compress` Headroom را فراخوانی کرده، سپس مسیریابی معمولی، بازگشت، احراز هویت و پیگیری مصرف را ادامه می‌دهد: + +``` +کلاینت → 9Router → Headroom /v1/compress → 9Router → ارائه‌دهنده +``` + +راه‌اندازی محلی: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +در داشبورد → نقطه پایانی → ذخیره‌ساز توکن → Headroom فعال کنید. آدرس پیش‌فرض: `http://localhost:8787`. + +مثال‌های داکر: + +```bash +# سرویس Headroom در همان شبکه داکر +http://headroom:8787 + +# Headroom در حال اجرا روی ماشین میزبان +http://host.docker.internal:8787 +``` + +اگر Headroom از کار بیفتد یا خطا برگرداند، 9Router به‌حالت بازگشت باز می‌شود و درخواست اصلی را ارسال می‌کند. + +### 🐴 دم‌اسب (توسعه‌دهنده ارشد تنبل) + +دم‌اسب یک پرامپت سیستمی _"توسعه‌دهنده ارشد تنبل"_ را به هر درخواست تزریق می‌کند و LLM را به سمت کدنویسی حداقلی و YAGNI-first سوق می‌دهد — حذف به جای افزودن، کتابخانه استاندارد به جای وابستگی‌های جدید، یک خطی به جای انتزاعات. اقتباس شده از [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **سبک** — آنچه خواسته شده را بساز، جایگزین تنبل‌تر را نام ببر. +- **کامل** — نردبان YAGNI اعمال می‌شود: کتابخانه استاندارد → بومی → وابستگی‌های موجود → یک خطی → حداقل کد. +- **فوق‌سبک** — افراط‌گرای YAGNI: اول حذف، یک خطی را ارسال کن، بقیه نیازمندی را در همان پاسخ به چالش بکش. + +``` +بدون دم‌اسب: کد پرحجم، انتزاعات اضافی، داربست‌های "فقط در صورت نیاز" +با دم‌اسب: کوتاه‌ترین دیف کاری، بدون انتزاعات درخواست نشده، توکن کمتر +``` + +هرگز موارد زیر را قربانی نمی‌کند: اعتبارسنجی ورودی، مدیریت خطا که از از دست رفتن داده جلوگیری می‌کند، امنیت، دسترس‌پذیری، یا هر چیزی که به‌صراحت درخواست شده باشد. در داشبورد → نقطه پایانی → دم‌اسب فعال کنید. با حالت غارنشین (مختصر بودن خروجی) و RTK (فشرده‌سازی ورودی) ترکیب می‌شود. + +### 🎯 بازگشت هوشمند ۳ لایه + +ترکیب‌هایی با بازگشت خودکار ایجاد کنید: + +``` +ترکیب: "my-coding-stack" + 1. cc/claude-opus-4-6 (اشتراک شما) + 2. glm/glm-4.7 (پشتیبان ارزان، ۰.۶ دلار/میلیون) + 3. if/kimi-k2-thinking (بازگشت رایگان) + +→ وقتی سهمیه تمام شود یا خطا رخ دهد، به‌طور خودکار تغییر می‌کند +``` + +### 📊 پیگیری سهمیه به‌روز + +- مصرف توکن به ازای هر ارائه‌دهنده +- شمارش معکوس بازنشانی (۵ ساعته، روزانه، هفتگی) +- تخمین هزینه برای لایه‌های پولی +- گزارش‌های هزینه ماهانه + +### 🔄 ترجمه قالب + +ترجمه یکپارچه بین قالب‌ها: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- ابزار خط فرمان شما قالب OpenAI ارسال می‌کند → 9Router ترجمه می‌کند → ارائه‌دهنده قالب بومی دریافت می‌کند +- با هر ابزاری که از نقاط پایانی سفارشی OpenAI پشتیبانی می‌کند کار می‌کند + +### 👥 پشتیبانی از چند حساب + +- افزودن چند حساب برای هر ارائه‌دهنده +- مسیریابی خودکار گردشی یا اولویت‌محور +- بازگشت به حساب بعدی وقتی یکی به سهمیه رسید + +### 🔄 بازسازی خودکار توکن + +- توکن‌های OAuth به‌طور خودکار قبل از انقضا بازسازی می‌شوند +- بدون نیاز به احراز هویت مجدد دستی +- تجربه یکپارچه در همه ارائه‌دهندگان + +### 🎨 ترکیب‌های سفارشی + +- ایجاد ترکیب‌های نامحدود مدل +- ترکیب لایه‌های اشتراک، ارزان و رایگان +- نام‌گذاری ترکیب‌ها برای دسترسی آسان +- اشتراک‌گذاری ترکیب‌ها بین دستگاه‌ها با همگام‌سازی ابری + +### 📝 ثبت درخواست + +- فعال‌سازی حالت اشکال‌زدایی برای لاگ‌های کامل درخواست/پاسخ +- پیگیری فراخوانی‌های API، هدرها و محموله‌ها +- عیب‌یابی مسائل یکپارچه‌سازی +- خروجی لاگ‌ها برای تحلیل + +### 💾 همگام‌سازی ابری + +- همگام‌سازی ارائه‌دهندگان، ترکیب‌ها و تنظیمات بین دستگاه‌ها +- همگام‌سازی خودکار در پس‌زمینه +- ذخیره‌سازی رمزگذاری شده امن +- دسترسی به تنظیمات خود از هر جا + +#### نکات اجرای ابری + +- در تولید از متغیرهای سمت سرور ابری استفاده کنید: + - `BASE_URL` (آدرس داخلی بازگشت برای برنامه‌ریز همگام‌سازی) + - `CLOUD_URL` (آدرس پایه نقطه پایانی همگام‌سازی ابری) +- `NEXT_PUBLIC_BASE_URL` و `NEXT_PUBLIC_CLOUD_URL` همچنان برای سازگاری/رابط کاربری پشتیبانی می‌شوند، اما زمان اجرای سرور اکنون `BASE_URL`/`CLOUD_URL` را اولویت می‌دهد. +- درخواست‌های همگام‌سازی ابری اکنون از زمان‌بندی + رفتار شکست سریع برای جلوگیری از هنگ کردن رابط کاربری در صورت عدم دسترسی شبکه ابری/DNS استفاده می‌کنند. + +### 📊 تحلیل استفاده + +- پیگیری مصرف توکن به ازای هر ارائه‌دهنده و مدل +- تخمین هزینه و روندهای هزینه +- گزارش‌های ماهانه و بینش‌ها +- بهینه‌سازی هزینه هوش مصنوعی + +> **💡 مهم - درک هزینه‌های داشبورد:** +> +> "هزینه" نمایش داده شده در تحلیل استفاده **فقط برای پیگیری و مقایسه** است. +> خود 9Router **هرگز از شما هزینه‌ای دریافت نمی‌کند**. شما فقط مستقیماً به ارائه‌دهندگان هزینه می‌پردازید (در صورت استفاده از خدمات پولی). +> +> **مثال:** اگر داشبورد شما "۲۹۰ دلار هزینه کل" را هنگام استفاده از مدل‌های iFlow نشان می‌دهد، این مبلغ چیزی است که در صورت استفاده مستقیم از APIهای پولی پرداخت می‌کردید. هزینه واقعی شما = **۰ دلار** (iFlow رایگان نامحدود است). +> +> به آن به عنوان "ردیاب پس‌انداز" فکر کنید که نشان می‌دهد با استفاده از مدل‌های رایگان یا مسیریابی از طریق 9Router چقدر صرفه‌جویی می‌کنید! + +### 🌐 استقرار در هر جا + +- 💻 **لوکال‌هست** - پیش‌فرض، آفلاین کار می‌کند +- ☁️ **VPS/ابر** - اشتراک‌گذاری بین دستگاه‌ها +- 🐳 **داکر** - استقرار با یک دستور +- 🚀 **Cloudflare Workers** - شبکه لبه جهانی + +
+ +--- + +## 💰 قیمت‌گذاری در یک نگاه + +| لایه | ارائه‌دهنده | هزینه | بازنشانی سهمیه | بهترین استفاده | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 ذخیره‌ساز توکن** | **RTK (ساخته شده)** | **رایگان** | همیشه روشن | **صرفه‌جویی ۲۰-۴۰٪ توکن در هر درخواست** | +| **💳 اشتراک** | Claude Code (Pro/Max) | ۲۰-۲۰۰ دلار/ماه | ۵ ساعته + هفتگی | قبلاً اشتراک دارید | +| | Codex (Plus/Pro) | ۲۰-۲۰۰ دلار/ماه | ۵ ساعته + هفتگی | کاربران OpenAI | +| | GitHub Copilot | ۱۰-۱۹ دلار/ماه | ماهانه | کاربران GitHub | +| | Cursor IDE | ۲۰ دلار/ماه | ماهانه | کاربران Cursor | +| **💰 ارزان** | GLM-5.1 / GLM-4.7 | ۰.۶ دلار/میلیون | روزانه ساعت ۱۰ صبح | پشتیبان بودجه | +| | MiniMax M2.7 | ۰.۲ دلار/میلیون | ۵ ساعته گردشی | ارزان‌ترین گزینه | +| | Kimi K2.5 | ۹ دلار/ماه مسطح | ۱۰ میلیون توکن/ماه | هزینه قابل پیش‌بینی | +| **🆓 رایگان** | Kiro AI | ۰ دلار | نامحدود | Claude 4.5 + GLM-5 + MiniMax رایگان | +| | OpenCode Free | ۰ دلار | نامحدود | بدون احراز هویت، دریافت خودکار مدل‌ها | +| | Vertex AI | ۳۰۰ دلار اعتبار | حساب‌های جدید GCP | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 نکته حرفه‌ای:** ترکیب RTK + Kiro AI + OpenCode Free = **۰ دلار هزینه + ۲۰-۴۰٪ صرفه‌جویی توکن**! + +--- + +### 📊 درک هزینه‌ها و صورتحساب 9Router + +**واقعیت صورتحساب 9Router:** + +✅ **نرم‌افزار 9Router = رایگان برای همیشه** (منبع باز، هرگز هزینه‌ای دریافت نمی‌کند) +✅ **"هزینه‌های" داشبورد = فقط نمایش/پیگیری** (صورتحساب واقعی نیستند) +✅ **شما مستقیماً به ارائه‌دهندگان هزینه می‌پردازید** (اشتراک‌ها یا هزینه‌های API) +✅ **ارائه‌دهندگان رایگان واقعاً رایگان هستند** (iFlow، Kiro، Qwen = ۰ دلار نامحدود) +❌ **9Router هرگز صورتحساب ارسال نمی‌کند** یا کارت شما را شارژ نمی‌کند + +**نحوه عملکرد نمایش هزینه:** + +داشبورد **هزینه‌های تخمینی** را نشان می‌دهد گویی مستقیماً از APIهای پولی استفاده می‌کنید. این **صورتحساب نیست** - این یک ابزار مقایسه برای نشان دادن پس‌انداز شماست. + +**سناریوی مثال:** + +``` +نمایش داشبورد: +• تعداد درخواست‌ها: ۱,۶۶۲ +• کل توکن‌ها: ۴۷ میلیون +• هزینه نمایشی: ۲۹۰ دلار + +بررسی واقعیت: +• ارائه‌دهنده: iFlow (رایگان نامحدود) +• پرداخت واقعی: ۰.۰۰ دلار +• منظور از ۲۹۰ دلار: مبلغی که با استفاده از مدل‌های رایگان پس‌انداز کرده‌اید! +``` + +**قوانین پرداخت:** + +- **ارائه‌دهندگان اشتراک** (Claude Code، Codex): مستقیماً از طریق وب‌سایت‌هایشان به آنها پرداخت کنید +- **ارائه‌دهندگان ارزان** (GLM، MiniMax): مستقیماً به آنها پرداخت کنید، 9Router فقط مسیریابی می‌کند +- **ارائه‌دهندگان رایگان** (iFlow، Kiro، Qwen): واقعاً برای همیشه رایگان، بدون هزینه پنهان +- **9Router**: هرگز هیچ هزینه‌ای دریافت نمی‌کند، همیشه + +--- + +## 🎯 موارد استفاده + +### مورد ۱: "من اشتراک Claude Pro دارم" + +**مشکل:** سهمیه بدون استفاده منقضی می‌شود، محدودیت نرخ در حین کدنویسی سنگین + +**راه‌حل:** + +``` +ترکیب: "maximize-claude" + 1. cc/claude-opus-4-7 (استفاده کامل از اشتراک) + 2. glm/glm-5.1 (پشتیبان ارزان وقتی سهمیه تمام شد) + 3. kr/claude-sonnet-4.5 (بازگشت اضطراری رایگان) + +هزینه ماهانه: ۲۰ دلار (اشتراک) + حدود ۵ دلار (پشتیبان) = ۲۵ دلار کل +در مقابل ۲۰ دلار + برخورد با محدودیت = ناامیدی +``` + +### مورد ۲: "من هزینه صفر می‌خواهم" + +**مشکل:** توانایی پرداخت اشتراک را ندارم، به هوش مصنوعی کدنویسی قابل اعتماد نیاز دارم + +**راه‌حل:** + +``` +ترکیب: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان نامحدود) + 2. kr/glm-5 (GLM-5 رایگان از طریق Kiro) + 3. oc/ (OpenCode Free، بدون احراز هویت) + +هزینه ماهانه: ۰ دلار +کیفیت: مدل‌های آماده تولید + RTK صرفه‌جویی ۲۰-۴۰٪ توکن +``` + +### مورد ۳: "به کدنویسی ۲۴/۷ بدون وقفه نیاز دارم" + +**مشکل:** ضرب‌الاجل‌ها، توانایی پرداخت هزینه توقف را ندارم + +**راه‌حل:** + +``` +ترکیب: "always-on" + 1. cc/claude-opus-4-7 (بهترین کیفیت) + 2. cx/gpt-5.5 (اشتراک دوم) + 3. glm/glm-5.1 (ارزان، بازنشانی روزانه) + 4. minimax/MiniMax-M2.7 (ارزان‌ترین، بازنشانی ۵ ساعته) + 5. kr/claude-sonnet-4.5 (رایگان نامحدود) + +نتیجه: ۵ لایه بازگشت = بدون توقف +هزینه ماهانه: ۲۰-۲۰۰ دلار (اشتراک‌ها) + ۱۰-۲۰ دلار (پشتیبان) +``` + +### مورد ۴: "من هوش مصنوعی رایگان در OpenClaw می‌خواهم" + +**مشکل:** به دستیار هوش مصنوعی در برنامه‌های پیام‌رسان (واتساپ، تلگرام، اسلک...) نیاز دارم، کاملاً رایگان + +**راه‌حل:** + +``` +ترکیب: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان) + 2. kr/glm-5 (GLM-5 رایگان) + 3. kr/MiniMax-M2.5 (MiniMax رایگان) + +هزینه ماهانه: ۰ دلار +دسترسی از طریق: واتساپ، تلگرام، اسلک، دیسکورد، iMessage، سیگنال... +``` + +--- + +## ❓ سوالات متداول + +
+📊 چرا داشبورد من هزینه‌های بالا نشان می‌دهد؟ + +داشبورد مصرف توکن شما را پیگیری کرده و **هزینه‌های تخمینی** را نشان می‌دهد گویی مستقیماً از APIهای پولی استفاده می‌کنید. این **صورتحساب واقعی نیست** - این یک مرجع برای نشان دادن میزان پس‌انداز شما با استفاده از مدل‌های رایگان یا اشتراک‌های موجود از طریق 9Router است. + +**مثال:** + +- **داشبورد نشان می‌دهد:** "۲۹۰ دلار هزینه کل" +- **واقعیت:** شما از iFlow (رایگان نامحدود) استفاده می‌کنید +- **هزینه واقعی شما:** **۰.۰۰ دلار** +- **منظور از ۲۹۰ دلار:** مبلغی که با استفاده از مدل‌های رایگان به جای APIهای پولی **پس‌انداز** کرده‌اید! + +نمایش هزینه یک "ردیاب پس‌انداز" است تا به شما در درک الگوهای مصرف و فرصت‌های بهینه‌سازی کمک کند. + +
+ +
+💳 آیا توسط 9Router شارژ می‌شوم؟ + +**خیر.** 9Router نرم‌افزاری رایگان و منبع باز است که روی رایانه خودتان اجرا می‌شود. هرگز از شما هزینه‌ای دریافت نمی‌کند. + +**شما فقط پرداخت می‌کنید:** + +- ✅ **ارائه‌دهندگان اشتراک** (Claude Code ۲۰ دلار/ماه، Codex ۲۰-۲۰۰ دلار/ماه) → مستقیماً در وب‌سایت‌هایشان به آنها پرداخت کنید +- ✅ **ارائه‌دهندگان ارزان** (GLM، MiniMax) → مستقیماً به آنها پرداخت کنید، 9Router فقط درخواست‌های شما را مسیریابی می‌کند +- ❌ **خود 9Router** → **هرگز هیچ هزینه‌ای دریافت نمی‌کند، همیشه** + +9Router یک پروکسی/مسیریاب محلی است. کارت اعتباری شما را ندارد، نمی‌تواند صورتحساب ارسال کند و سیستم صورتحساب ندارد. این نرم‌افزار کاملاً رایگان است. + +
+ +
+🆓 آیا ارائه‌دهندگان رایگان واقعاً نامحدود هستند؟ + +**بله!** ارائه‌دهندگان رایگان فعلی (Kiro، OpenCode Free، Vertex) واقعاً رایگان هستند و **هزینه پنهانی ندارند**. + +اینها خدمات رایگانی هستند که توسط آن شرکت‌ها ارائه می‌شوند: + +- **Kiro AI**: Claude 4.5 + GLM-5 + MiniMax نامحدود رایگان از طریق AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: پروکسی عبوری بدون احراز هویت، مدل‌ها به‌طور خودکار از `opencode.ai/zen/v1/models` دریافت می‌شوند +- **Vertex AI**: ۳۰۰ دلار اعتبار رایگان برای حساب‌های جدید Google Cloud (۹۰ روز) + +9Router فقط درخواست‌های شما را به آنها مسیریابی می‌کند - هیچ "دام" یا صورتحساب آینده‌ای وجود ندارد. آنها واقعاً خدمات رایگان هستند و 9Router استفاده از آنها را با پشتیبانی از بازگشت آسان می‌کند. + +**لایه‌های رایگان متوقف شده (دیگر توصیه نمی‌شوند):** + +- ❌ **iFlow**: قبلاً رایگان نامحدود بود، اکنون به پولی تغییر کرده است (۲۰۲۶) +- ❌ **Qwen Code**: لایه رایگان OAuth توسط علی‌بابا در ۲۰۲۶-۰۴-۱۵ متوقف شد +- ❌ **Gemini CLI**: همچنان کار می‌کند، اما استفاده از آن با ابزارهای غیر CLI (Claude، Codex، Cursor...) ممکن است منجر به مسدود شدن حساب شود — فقط در صورت استفاده از خود Gemini CLI از آن استفاده کنید + +
+ +
+💰 چگونه هزینه‌های واقعی هوش مصنوعی خود را به حداقل برسانم؟ + +**استراتژی اولویت با رایگان:** + +۱. **با ترکیب ۱۰۰٪ رایگان شروع کنید:** + + ``` + 1. gc/gemini-3-flash (۱۸۰ هزار توکن/ماه رایگان از گوگل) + 2. if/kimi-k2-thinking (نامحدود رایگان از iFlow) + 3. qw/qwen3-coder-plus (نامحدود رایگان از Qwen) + ``` + + **هزینه: ۰ دلار/ماه** + +۲. **در صورت نیاز، پشتیبان ارزان اضافه کنید:** + + ``` + 4. glm/glm-4.7 (۰.۶ دلار/میلیون توکن) + ``` + + **هزینه اضافی: فقط برای چیزی که واقعاً استفاده می‌کنید پرداخت کنید** + +۳. **از ارائه‌دهندگان اشتراک در آخر استفاده کنید:** + - فقط در صورتی که از قبل آنها را دارید + - 9Router با پیگیری سهمیه به حداکثر رساندن ارزش آنها کمک می‌کند + +**نتیجه:** اکثر کاربران می‌توانند با استفاده فقط از لایه‌های رایگان با ۰ دلار/ماه کار کنند! + +
+ +
+📈 اگر مصرف من ناگهان افزایش یابد چه؟ + +بازگشت هوشمند 9Router از هزینه‌های غافلگیرکننده جلوگیری می‌کند: + +**سناریو:** شما در یک ماراتن کدنویسی هستید و سهمیه‌های خود را تمام می‌کنید + +**بدون 9Router:** + +- ❌ برخورد با محدودیت نرخ → کار متوقف می‌شود → ناامیدی +- ❌ یا: به‌طور تصادفی صورت‌حساب‌های عظیم API جمع می‌کنید + +**با 9Router:** + +- ✅ اشتراک به حد مجاز می‌رسد → بازگشت خودکار به لایه ارزان +- ✅ لایه ارزان گران می‌شود → بازگشت خودکار به لایه رایگان +- ✅ هرگز کدنویسی را متوقف نکنید → هزینه‌های قابل پیش‌بینی + +**شما کنترل دارید:** محدودیت‌های هزینه را برای هر ارائه‌دهنده در داشبورد تنظیم کنید و 9Router به آنها احترام می‌گذارد. + +
+ +--- + +## 📖 راهنمای راه‌اندازی + +
+🔐 ارائه‌دهندگان اشتراک (حداکثر کردن ارزش) + +### Claude Code (Pro/Max) + +```bash +داشبورد → ارائه‌دهندگان → اتصال Claude Code +→ ورود OAuth → بازسازی خودکار توکن +→ پیگیری سهمیه ۵ ساعته + هفتگی + +مدل‌ها: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**نکته حرفه‌ای:** از Opus برای کارهای پیچیده و Sonnet برای سرعت استفاده کنید. 9Router سهمیه را به ازای هر مدل پیگیری می‌کند! + +### OpenAI Codex (Plus/Pro) + +```bash +داشبورد → ارائه‌دهندگان → اتصال Codex +→ ورود OAuth (پورت ۱۴۵۵) +→ بازنشانی ۵ ساعته + هفتگی + +مدل‌ها: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +داشبورد → ارائه‌دهندگان → اتصال GitHub +→ OAuth از طریق GitHub +→ بازنشانی ماهانه (اول ماه) + +مدل‌ها: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +داشبورد → ارائه‌دهندگان → اتصال Cursor +→ ورود OAuth +→ اشتراک ماهانه + +مدل‌ها: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 ارائه‌دهندگان ارزان (پشتیبان) + +### GLM-5.1 / GLM-4.7 (بازنشانی روزانه، ۰.۶ دلار/میلیون) + +۱. ثبت‌نام: [Zhipu AI](https://open.bigmodel.cn/) +۲. دریافت کلید API از Coding Plan +۳. داشبورد → افزودن کلید API: + - ارائه‌دهنده: `glm` + - کلید API: `your-key` + +**استفاده:** `glm/glm-5.1`، `glm/glm-5`، `glm/glm-4.7` + +**نکته حرفه‌ای:** Coding Plan ۳ برابر سهمیه با ۱/۷ هزینه ارائه می‌دهد! بازنشانی روزانه ساعت ۱۰:۰۰ صبح. + +### MiniMax M2.7 (بازنشانی ۵ ساعته، ۰.۲۰ دلار/میلیون) + +۱. ثبت‌نام: [MiniMax](https://www.minimax.io/) +۲. دریافت کلید API +۳. داشبورد → افزودن کلید API + +**استفاده:** `minimax/MiniMax-M2.7`، `minimax/MiniMax-M2.5` + +**نکته حرفه‌ای:** ارزان‌ترین گزینه برای زمینه طولانی (۱ میلیون توکن)! + +### Kimi K2.5 (۹ دلار/ماه مسطح) + +۱. اشتراک: [Moonshot AI](https://platform.moonshot.ai/) +۲. دریافت کلید API +۳. داشبورد → افزودن کلید API + +**استفاده:** `kimi/kimi-k2.5`، `kimi/kimi-k2.5-thinking` + +**نکته حرفه‌ای:** ۹ دلار/ماه ثابت برای ۱۰ میلیون توکن = هزینه مؤثر ۰.۹۰ دلار/میلیون! + +
+ +
+🆓 ارائه‌دهندگان رایگان (توصیه شده) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax رایگان) + +```bash +داشبورد → اتصال Kiro +→ AWS Builder ID، AWS IAM Identity Center، Google، یا GitHub +→ استفاده نامحدود + +مدل‌ها: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**نکته حرفه‌ای:** بهترین گزینه رایگان برای Claude. بدون کلید API، بدون پرداخت، کاملاً نامحدود. + +### OpenCode Free (بدون احراز هویت، دریافت خودکار مدل‌ها) + +```bash +داشبورد → اتصال OpenCode Free +→ بدون نیاز به ورود (پروکسی عبوری) +→ مدل‌ها به‌طور خودکار از opencode.ai/zen/v1/models دریافت می‌شوند +``` + +**نکته حرفه‌ای:** سریع‌ترین راه‌اندازی. فقط متصل شوید و کدنویسی را شروع کنید. + +### Vertex AI (۳۰۰ دلار اعتبار رایگان برای حساب‌های جدید GCP) + +```bash +داشبورد → اتصال Vertex AI +→ آپلود JSON حساب سرویس Google Cloud +→ فعال‌سازی API Vertex AI در پروژه GCP خود + +مدل‌ها: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen از طریق Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**نکته حرفه‌ای:** حساب‌های جدید Google Cloud ۳۰۰ دلار اعتبار رایگان به مدت ۹۰ روز دریافت می‌کنند. برای کدنویسی روزانه کافی است. + +
+ +
+🎨 ایجاد ترکیب‌ها + +### مثال ۱: حداکثر اشتراک → پشتیبان ارزان + +``` +داشبورد → ترکیب‌ها → ایجاد جدید + +نام: premium-coding +مدل‌ها: + 1. cc/claude-opus-4-7 (اشتراک اصلی) + 2. glm/glm-5.1 (پشتیبان ارزان، ۰.۶ دلار/میلیون) + 3. minimax/MiniMax-M2.7 (ارزان‌ترین بازگشت، ۰.۲۰ دلار/میلیون) + +استفاده در CLI: premium-coding + +مثال هزینه ماهانه (۱۰۰ میلیون توکن): + ۸۰ میلیون از طریق Claude (اشتراک): ۰ دلار اضافی + ۱۵ میلیون از طریق GLM: ۹ دلار + ۵ میلیون از طریق MiniMax: ۱ دلار + کل: ۱۰ دلار + اشتراک شما +``` + +### مثال ۲: فقط رایگان (هزینه صفر) + +``` +نام: free-combo +مدل‌ها: + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان نامحدود) + 2. kr/glm-5 (GLM-5 رایگان از طریق Kiro) + 3. vertex/gemini-3.1-pro-preview (۳۰۰ دلار اعتبار رایگان) + +هزینه: ۰ دلار برای همیشه (+ صرفه‌جویی ۲۰-۴۰٪ توکن با RTK)! +``` + +
+ +
+🔧 یکپارچه‌سازی با CLI + +### Cursor IDE + +``` +تنظیمات → مدل‌ها → پیشرفته: + آدرس پایه API OpenAI: http://localhost:20128/v1 + کلید API OpenAI: [از داشبورد 9router] + مدل: cc/claude-opus-4-7 +``` + +یا از ترکیب استفاده کنید: `premium-coding` + +### Claude Code + +ویرایش `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**گزینه ۱ — داشبورد (توصیه می‌شود):** + +``` +داشبورد → ابزارهای CLI → OpenClaw → انتخاب مدل → اعمال +``` + +**گزینه ۲ — دستی:** ویرایش `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **توجه:** OpenClaw فقط با 9Router محلی کار می‌کند. برای جلوگیری از مشکلات وضوح IPv6 از `127.0.0.1` به جای `localhost` استفاده کنید. + +### Cline / Continue / RooCode + +``` +ارائه‌دهنده: سازگار با OpenAI +آدرس پایه: http://localhost:20128/v1 +کلید API: [از داشبورد] +مدل: cc/claude-opus-4-7 +``` + +
+ +
+🚀 استقرار + +### استقرار در VPS + +```bash +# کلون و نصب +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# پیکربندی +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# شروع +npm run start + +# یا استفاده از PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### داکر + +تصاویر منتشر شده (چند پلتفرم `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**شروع سریع (استفاده از تصویر منتشر شده):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ باز کردن http://localhost:20128 + +**ساخت از سورس (توسعه):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**پیش‌فرض‌های کانتینر:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**دستورات مفید:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # به‌روزرسانی به آخرین نسخه +``` + +**ماندگاری داده:** `$HOME/.9router/db/data.sqlite` در میزبان ↔ `/app/data/db/data.sqlite` در کانتینر. + +### متغیرهای محیطی + +| متغیر | پیش‌فرض | توضیحات | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | تولید خودکار (`~/.9router/jwt-secret`) | راز امضای JWT برای کوکی احراز هویت داشبورد (برای اشتراک بین نمونه‌ها بازنویسی کنید) | +| `INITIAL_PASSWORD` | `123456` | رمز عبور اولین ورود در صورت عدم وجود هش ذخیره شده | +| `DATA_DIR` | `~/.9router` | مکان اصلی داده‌های برنامه (SQLite در `$DATA_DIR/db/data.sqlite`) | +| `PORT` | پیش‌فرض فریم‌ورک | پورت سرویس (`۲۰۱۲۸` در مثال‌ها) | +| `HOSTNAME` | پیش‌فرض فریم‌ورک | هاست بایند (داکر پیش‌فرض `۰.۰.۰.۰` است) | +| `NODE_ENV` | پیش‌فرض زمان اجرا | برای استقرار `production` را تنظیم کنید | +| `BASE_URL` | `http://localhost:20128` | آدرس پایه داخلی سمت سرور که توسط کارهای همگام‌سازی ابری استفاده می‌شود | +| `CLOUD_URL` | `https://9router.com` | آدرس پایه نقطه پایانی همگام‌سازی ابری سمت سرور | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | آدرس پایه عمومی/سازگار با گذشته (برای زمان اجرای سرور `BASE_URL` را ترجیح دهید) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | آدرس ابری عمومی/سازگار با گذشته (برای زمان اجرای سرور `CLOUD_URL` را ترجیح دهید) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | راز HMAC برای کلیدهای API تولید شده | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | نمک برای هش کردن شناسه ماشین پایدار | +| `ENABLE_REQUEST_LOGS` | `false` | لاگ‌های درخواست/پاسخ را در `logs/` فعال می‌کند | +| `AUTH_COOKIE_SECURE` | `false` | کوکی احراز هویت `Secure` را اعمال می‌کند (در پشت پروکسی معکوس HTTPS `true` تنظیم کنید) | +| `REQUIRE_API_KEY` | `false` | اعمال کلید API Bearer در مسیرهای `/v1/*` (برای استقرارهای در معرض اینترنت توصیه می‌شود) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | خالی | پروکسی خروجی اختیاری برای فراخوانی‌های ارائه‌دهنده بالا دست | +| `SEARXNG_URL` | `http://localhost:8888/search` | نقطه پایانی برای ارائه‌دهنده جستجوی وب SearXNG ساخته شده بدون احراز هویت | + +نکات: + +- متغیرهای پروکسی با حروف کوچک نیز پشتیبانی می‌شوند: `http_proxy`، `https_proxy`، `all_proxy`، `no_proxy`. +- `.env` در تصویر داکر تعبیه نشده است (`.dockerignore`)؛ پیکربندی زمان اجرا را با `--env-file` یا `-e` تزریق کنید. +- در ویندوز، می‌توان از `APPDATA` برای وضوح مسیر ذخیره‌سازی محلی استفاده کرد. +- `INSTANCE_NAME` در مستندات قدیمی/الگوهای env ظاهر می‌شود، اما در حال حاضر در زمان اجرا استفاده نمی‌شود. + +### فایل‌های زمان اجرا و ذخیره‌سازی + +- وضعیت اصلی برنامه: `${DATA_DIR}/db/data.sqlite` (SQLite — ارائه‌دهندگان، ترکیب‌ها، نام‌های مستعار، کلیدها، تنظیمات، تاریخچه استفاده) +- پشتیبان‌گیری خودکار: `${DATA_DIR}/db/backups/` +- لاگ‌های اختیاری درخواست/مترجم: `/logs/...` وقتی `ENABLE_REQUEST_LOGS=true` +- هر دو `${DATA_DIR}` و `~/.9router` در یک کانتینر داکر به یک مکان اشاره می‌کنند — symlink `/root/.9router -> /app/data` در زمان ساخت ایجاد می‌شود. + +
+ +--- + +## 📊 مدل‌های موجود + +
+مشاهده همه مدل‌های موجود + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - اشتراک: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - ۰.۶ دلار/میلیون: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - ۰.۲ دلار/میلیون: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - ۹ دلار/ماه مسطح: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - رایگان نامحدود: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - رایگان بدون احراز هویت: + +- دریافت خودکار از `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - ۳۰۰ دلار اعتبار رایگان: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 عیب‌یابی + +**"مدل زبان پیامی ارائه نکرد"** + +- سهمیه ارائه‌دهنده تمام شده → پیگیری سهمیه در داشبورد را بررسی کنید +- راه‌حل: از بازگشت ترکیبی استفاده کنید یا به لایه ارزان‌تر تغییر دهید + +**محدودیت نرخ درخواست** + +- سهمیه اشتراک تمام شده → بازگشت به GLM/MiniMax +- ترکیب اضافه کنید: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**توکن OAuth منقضی شده است** + +- توسط 9Router به‌طور خودکار بازسازی می‌شود +- اگر مشکل ادامه داشت: داشبورد → ارائه‌دهنده → اتصال مجدد + +**هزینه‌های بالا** + +- RTK را در داشبورد → تنظیمات نقطه پایانی فعال کنید (پیش‌فرض روشن است، ۲۰-۴۰٪ توکن صرفه‌جویی می‌کند) +- آمار مصرف را در داشبورد بررسی کنید +- مدل اصلی را به GLM/MiniMax تغییر دهید +- برای کارهای غیر حیاتی از لایه رایگان (Kiro، OpenCode Free، Vertex) استفاده کنید + +**داشبورد در پورت اشتباه باز می‌شود** + +- `PORT=20128` و `NEXT_PUBLIC_BASE_URL=http://localhost:20128` را تنظیم کنید + +**اولین ورود کار نمی‌کند** + +- `INITIAL_PASSWORD` را در `.env` بررسی کنید +- در صورت تنظیم نشدن، رمز عبور پیش‌فرض `123456` است + +**لاگ‌های درخواست در `logs/` وجود ندارد** + +- `ENABLE_REQUEST_LOGS=true` را تنظیم کنید + +--- + +## 🛠️ پشته فنی + +- **زمان اجرا**: Node.js 20+ +- **فریم‌ورک**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **پایگاه داده**: SQLite (better-sqlite3 / node:sqlite / بازگشت sql.js) +- **پخش جریانی**: رویدادهای ارسال شده از سرور (SSE) +- **احراز هویت**: OAuth 2.0 (PKCE) + JWT + کلیدهای API + +--- + +## 📝 مرجع API + +### تکمیل‌های چت + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### لیست مدل‌ها + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ همه مدل‌ها + ترکیب‌ها را در قالب OpenAI برمی‌گرداند +``` + +## 📧 پشتیبانی + +- **وب‌سایت**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **مسائل**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 مشارکت‌کنندگان + +با تشکر از همه مشارکت‌کنندگانی که به بهتر شدن 9Router کمک کردند! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 نمودار ستاره + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 فورک‌ها + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — یک فورک کامل TypeScript از 9Router. بیش از ۳۶ ارائه‌دهنده، بازگشت خودکار ۴ لایه، APIهای چندوجهی (تصاویر، جاسازی‌ها، صدا، TTS)، قطع‌کننده مدار، حافظه پنهان معنایی، ارزیابی‌های LLM و داشبوردی زیبا اضافه می‌کند. بیش از ۳۶۸ تست واحد. از طریق npm و داکر در دسترس است. + +--- + +## 🙏 قدردانی + +ساخته شده بر روی شانه‌های غول‌ها: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — پیاده‌سازی اصلی Go که الهام‌بخش این پورت جاوااسکریپت بود. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — ذخیره‌ساز توکن Rust. 9Router خط لوله فشرده‌سازی آن را به JS منتقل می‌کند → **۲۰-۴۰٪- توکن ورودی** در هر درخواست. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) توسط **[@JuliusBrussee](https://github.com/JuliusBrussee)** — پرامپت ویروسی _"چرا از توکن زیاد استفاده کنی وقتی توکن کم کار را انجام می‌دهد"_. 9Router پرامپت آن را تطبیق می‌دهد → **۶۵٪- توکن خروجی**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) توسط **[@DietrichGebert](https://github.com/DietrichGebert)** — مهارت _"توسعه‌دهنده ارشد تنبل"_. 9Router نردبان YAGNI-first آن را تزریق می‌کند → **توکن کمتر، کد کمتر، دیف‌های کوتاه‌تر**. + +تشکر فراوان از این نویسندگان — بدون کار آنها، ویژگی‌های ذخیره‌سازی توکن 9Router وجود نداشت. ⭐ آنها را در GitHub بدهید! + +--- + +## 📄 مجوز + +مجوز MIT - برای جزئیات به [LICENSE](LICENSE) مراجعه کنید. + +--- + +
+ ساخته شده با ❤️ برای توسعه‌دهندگانی که ۲۴/۷ کدنویسی می‌کنند +
diff --git a/i18n/README.fr.md b/i18n/README.fr.md new file mode 100644 index 00000000..d93df014 --- /dev/null +++ b/i18n/README.fr.md @@ -0,0 +1,1445 @@ +
+ Tableau de bord 9Router + + # 9Router - Routeur IA GRATUIT et économiseur de tokens + + **N'arrêtez jamais de coder. Économisez 20 à 40 % de tokens avec RTK + bascule automatique vers des modèles d'IA GRATUITS et bon marché.** + + **Connectez tous vos outils de codage IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) à plus de 40 fournisseurs d'IA et plus de 100 modèles.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Démarrage rapide](#-démarrage-rapide) • [💡 Fonctionnalités](#-fonctionnalités-clés) • [📖 Configuration](#-guide-dinstallation) • [🌐 Site web](https://9router.com) + +[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) • [🇪🇸 Español](./README.es.md) • [🇫🇷 Français](./README.fr.md) + +
+ +--- + +## 🤔 Pourquoi 9Router ? + +**Arrêtez de gaspiller de l'argent, des tokens et de buter contre les limites :** + +- ❌ Le quota de votre abonnement expire inutilisé chaque mois +- ❌ Les limites de débit vous interrompent en plein codage +- ❌ Les sorties des outils (git diff, grep, ls...) consomment vos tokens rapidement +- ❌ Des API coûteuses (20 à 50 $/mois par fournisseur) +- ❌ Changement manuel entre les fournisseurs + +**9Router résout tout cela :** + +- ✅ **Économiseur de tokens RTK** - Compresse automatiquement le contenu de tool_result et économise 20 à 40 % de tokens par requête +- ✅ **Maximise les abonnements** - Suit le quota et utilise chaque bit avant sa réinitialisation +- ✅ **Bascule automatique** - Abonnement → Bon marché → Gratuit, zéro temps d'arrêt +- ✅ **Multi-comptes** - Round-robin entre les comptes de chaque fournisseur +- ✅ **Universel** - Fonctionne avec Claude Code, Codex, Cursor, Cline et tout outil CLI + +--- + +## 🔄 Comment ça marche + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Démarrage rapide + +**1. Installez globalement :** + +```bash +npm install -g 9router +9router +``` + +🎉 Le tableau de bord s'ouvre sur `http://localhost:20128` + +**2. Connectez un fournisseur GRATUIT (aucune inscription requise) :** + +Tableau de bord → Providers → Connectez **Kiro AI** (Claude gratuit et illimité) ou **OpenCode Free** (sans authentification) → C'est tout ! + +**3. Utilisez-le dans votre outil CLI :** + +``` +Paramètres de Claude Code/Codex/OpenClaw/Cursor/Cline : + Endpoint : http://localhost:20128/v1 + API Key : [copiez depuis le tableau de bord] + Model : kr/claude-sonnet-4.5 +``` + +**Et voilà !** Commencez à coder avec des modèles d'IA GRATUITS. + +**Alternative : exécuter depuis le code source (ce dépôt) :** + +Le paquet de ce dépôt est privé (`9router-app`), donc l'exécution depuis le code source/Docker est le chemin de développement local attendu. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode production : + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL par défaut : + +- Tableau de bord : `http://localhost:20128/dashboard` +- API compatible OpenAI : `http://localhost:20128/v1` + +--- + +## 🎥 Guides vidéo + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **Vous avez réalisé une vidéo sur 9Router ?** Envoyez une [Pull Request](https://github.com/decolua/9router/pulls) en ajoutant votre vidéo à cette section — nous la fusionnerons ! + +--- + +## 🛠️ Outils CLI pris en charge + +9Router fonctionne parfaitement avec tous les principaux outils de codage IA : + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Fournisseurs pris en charge + +### 🔐 Fournisseurs OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Fournisseurs gratuits + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
GRATUIT illimité
+
+ OpenCode Free
+ OpenCode Free
+ Sans authentification • Modèles automatiques
GRATUIT illimité
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
300 $ de crédit gratuit
+
+
+ +> **Remarque :** Les niveaux gratuits d'iFlow, Qwen et Gemini CLI ont été interrompus en 2026. Utilisez plutôt Kiro / OpenCode Free / Vertex. + +### 🔑 Fournisseurs avec clé API (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...et plus de 20 autres fournisseurs, dont Nebius, Chutes, Hyperbolic et des endpoints personnalisés compatibles OpenAI/Anthropic

+
+ +--- + +## 💡 Fonctionnalités clés + +| Fonctionnalité | Ce qu'elle fait | Pourquoi c'est important | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| 🚀 **Économiseur de tokens RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compresse les sorties des outils (`git diff`, `grep`, `ls`, `tree`...) avant de les envoyer au LLM | Économise **20 à 40 % de tokens d'entrée** par requête | +| 🧠 **Économiseur de tokens Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy externe optionnel `/v1/compress` avant le routage vers le fournisseur | Économise plus de tokens de contexte sans changer les clients | +| 🪨 **Mode cavernicole** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Injecte un prompt en langage cavernicole → le LLM répond de manière concise, le contenu technique est conservé | Économise **jusqu'à 65 % de tokens de sortie** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Injecte un prompt de "dev sénior fainéant" → le LLM écrit un code minimal, YAGNI d'abord (Lite/Full/Ultra) | **Moins de tokens de sortie, moins de refactorisation** | +| 🎯 **Repli intelligent à 3 niveaux** | Routage automatique : Abonnement → Bon marché → Gratuit | N'arrêtez jamais de coder, zéro temps d'arrêt | +| 📊 **Suivi de quota en temps réel** | Compteur de tokens en direct + compte à rebours de réinitialisation | Maximisez la valeur de votre abonnement | +| 🔄 **Traduction de formats** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Fonctionne avec n'importe quel outil CLI | +| 👥 **Prise en charge multi-comptes** | Plusieurs comptes par fournisseur | Équilibrage de charge + redondance | +| 🔄 **Renouvellement automatique des tokens** | Les tokens OAuth se renouvellent automatiquement | Plus besoin de se reconnecter manuellement | +| 🎨 **Combos personnalisés** | Créez des combinaisons de modèles illimitées | Adaptez le repli à vos besoins | +| 📝 **Journalisation des requêtes** | Mode débogage avec journaux complets de requêtes/réponses | Résolvez facilement les problèmes | +| 💾 **Synchronisation cloud** | Synchronisez la configuration entre vos appareils | La même configuration partout | +| 📊 **Analytique d'utilisation** | Suivez tokens, coûts et tendances au fil du temps | Optimisez vos dépenses | +| 🌐 **Déployez partout** | Localhost, VPS, Docker, Cloudflare Workers | Options de déploiement flexibles | + +
+📖 Détails des fonctionnalités + +### 🚀 Économiseur de tokens RTK + +Les sorties des outils (`git diff`, `grep`, `find`, `ls`, `tree`, vidages de journaux...) consomment souvent 30 à 50 % de votre budget de prompt. RTK les détecte et applique une compression intelligente et sans perte **avant** que la requête n'atteigne le LLM : + +- **Filtres :** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Détection automatique :** Aucune configuration requise — RTK inspecte le premier 1 Ko de chaque `tool_result` et choisit le bon filtre. +- **Sûr par conception :** Si un filtre échoue, génère une erreur ou rend la sortie plus volumineuse, RTK conserve silencieusement le texte original. Les erreurs ne cassent jamais votre requête. +- **Universel :** Fonctionne dans tous les formats (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) car il s'exécute **avant** toute traduction de format. +- **Activé par défaut :** Activez-le ou désactivez-le à tout moment dans Tableau de bord → Paramètres d'Endpoint. + +``` +Sans RTK : 47K tokens envoyés au LLM +Avec RTK : 28K tokens envoyés au LLM (40 % économisés · même contexte · même réponse) +``` + +### 🧠 Économiseur de tokens Headroom + +Headroom est optionnel et s'exécute séparément. 9Router appelle l'endpoint local `/v1/compress` de Headroom, puis conserve le routage, le repli, l'authentification et le suivi d'utilisation normaux : + +``` +Client → 9Router → Headroom /v1/compress → 9Router → fournisseur +``` + +Configuration locale : + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Activez-le dans Tableau de bord → Endpoint → Token Saver → Headroom. URL par défaut : `http://localhost:8787`. + +Exemples Docker : + +```bash +# Service Headroom dans le même réseau Docker +http://headroom:8787 + +# Headroom exécuté sur la machine hôte +http://host.docker.internal:8787 +``` + +Si Headroom est indisponible ou renvoie une erreur, 9Router bascule en mode dégradé et envoie la requête originale. + +### 🐴 Ponytail (Dev sénior fainéant) + +Ponytail injecte un prompt système de _"dev sénior fainéant"_ dans chaque requête, orientant le LLM vers un code minimal et YAGNI d'abord — la suppression plutôt que l'ajout, la bibliothèque standard plutôt que de nouvelles dépendances, une ligne plutôt que des abstractions. Adapté de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construisez ce qui est demandé, nommez l'alternative plus fainéante. +- **Full** — Hiérarchie YAGNI appliquée : stdlib → natif → dépendances existantes → une ligne → code minimal. +- **Ultra** — Extrémiste du YAGNI : suppression d'abord, livrez la solution d'une ligne, remettez en question le reste de l'exigence dans la même réponse. + +``` +Sans Ponytail : code verbeux, abstractions superflues, échafaudage "au cas où" +Avec Ponytail : diff fonctionnel le plus court, aucune abstraction non demandée, moins de tokens +``` + +Ne sacrifie jamais : la validation des entrées, la gestion des erreurs qui évite la perte de données, la sécurité, l'accessibilité ni rien d'explicitement demandé. Activez-le dans Tableau de bord → Endpoint → Ponytail. Se cumule avec Caveman (concision de sortie) et RTK (compression d'entrée). + +### 🎯 Repli intelligent à 3 niveaux + +Créez des combos avec repli automatique : + +``` +Combo : "my-coding-stack" + 1. cc/claude-opus-4-6 (votre abonnement) + 2. glm/glm-4.7 (sauvegarde économique, $0.6/1M) + 3. if/kimi-k2-thinking (repli gratuit) + +→ Bascule automatiquement quand le quota est épuisé ou en cas d'erreur +``` + +### 📊 Suivi de quota en temps réel + +- Consommation de tokens par fournisseur +- Compte à rebours de réinitialisation (5 heures, quotidien, hebdomadaire) +- Estimation des coûts pour les niveaux payants +- Rapports de dépenses mensuels + +### 🔄 Traduction de formats + +Traduction transparente entre les formats : + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Votre outil CLI envoie le format OpenAI → 9Router le traduit → Le fournisseur reçoit le format natif +- Fonctionne avec tout outil prenant en charge les endpoints OpenAI personnalisés + +### 👥 Prise en charge multi-comptes + +- Ajoutez plusieurs comptes par fournisseur +- Routage automatique round-robin ou basé sur la priorité +- Repli vers le compte suivant quand un compte atteint son quota + +### 🔄 Renouvellement automatique des tokens + +- Les tokens OAuth se renouvellent automatiquement avant expiration +- Plus besoin de réauthentification manuelle +- Expérience transparente avec tous les fournisseurs + +### 🎨 Combos personnalisés + +- Créez des combinaisons de modèles illimitées +- Mélangez niveaux d'abonnement, économiques et gratuits +- Nommez vos combos pour un accès facile +- Partagez vos combos entre appareils grâce à la Synchronisation cloud + +### 📝 Journalisation des requêtes + +- Activez le mode débogage pour des journaux complets de requêtes/réponses +- Suivez les appels API, les en-têtes et les charges utiles +- Résolvez les problèmes d'intégration +- Exportez les journaux pour analyse + +### 💾 Synchronisation cloud + +- Synchronisez fournisseurs, combos et paramètres entre appareils +- Synchronisation automatique en arrière-plan +- Stockage chiffré et sécurisé +- Accédez à votre configuration depuis n'importe où + +#### Remarques sur le runtime cloud + +- Privilégiez les variables cloud côté serveur en production : + - `BASE_URL` (URL de rappel interne utilisée par le planificateur de synchronisation) + - `CLOUD_URL` (base de l'endpoint de synchronisation cloud) +- `NEXT_PUBLIC_BASE_URL` et `NEXT_PUBLIC_CLOUD_URL` restent prises en charge pour la compatibilité/UI, mais le runtime serveur privilégie désormais `BASE_URL`/`CLOUD_URL`. +- Les requêtes de synchronisation cloud utilisent désormais un délai d'expiration + un comportement de défaillance rapide pour éviter que l'UI ne se bloque quand le DNS/réseau cloud est indisponible. + +### 📊 Analytique d'utilisation + +- Suivez l'utilisation des tokens par fournisseur et modèle +- Estimation des coûts et tendances de dépenses +- Rapports et informations mensuels +- Optimisez vos dépenses IA + +> **💡 IMPORTANT - Comprendre les coûts du tableau de bord :** +> +> Le "coût" affiché dans l'Analytique d'utilisation est **uniquement à des fins de suivi et de comparaison**. +> 9Router lui-même **ne vous facture jamais** quoi que ce soit. Vous ne payez que les fournisseurs directement (si vous utilisez des services payants). +> +> **Exemple :** Si votre tableau de bord affiche "coût total de 290 $" alors que vous utilisez les modèles iFlow, cela représente +> ce que vous auriez payé en utilisant des API payantes directement. Votre coût réel = **0 $** (iFlow est gratuit et illimité). +> +> Considérez-le comme un "suivi d'économies" qui montre combien vous économisez en utilisant des modèles gratuits ou +> en routant via 9Router ! + +### 🌐 Déployez partout + +- 💻 **Localhost** - Par défaut, fonctionne hors ligne +- ☁️ **VPS/Cloud** - Partagez entre appareils +- 🐳 **Docker** - Déploiement en une commande +- 🚀 **Cloudflare Workers** - Réseau mondial de périphérie + +
+ +--- + +## 💰 Tarifs en un coup d'œil + +| Niveau | Fournisseur | Coût | Réinitialisation du quota | Idéal pour | +| ---------------------- | --------------------- | --------------- | ------------------------- | ----------------------------------------- | +| **🚀 ÉCONOMIE DE TOKENS** | **RTK (intégré)** | **GRATUIT** | Toujours actif | **Économisez 20 à 40 % de tokens sur CHAQUE requête** | +| **💳 ABONNEMENT** | Claude Code (Pro/Max) | 20 à 200 $/mois | 5 h + hebdomadaire | Déjà abonné | +| | Codex (Plus/Pro) | 20 à 200 $/mois | 5 h + hebdomadaire | Utilisateurs d'OpenAI | +| | GitHub Copilot | 10 à 19 $/mois | Mensuelle | Utilisateurs de GitHub | +| | Cursor IDE | 20 $/mois | Mensuelle | Utilisateurs de Cursor | +| **💰 BON MARCHÉ** | GLM-5.1 / GLM-4.7 | 0,6 $/1M | Quotidienne 10 h | Sauvegarde économique | +| | MiniMax M2.7 | 0,2 $/1M | Fenêtre de 5 heures | L'option la moins chère | +| | Kimi K2.5 | 9 $/mois forfait | 10M tokens/mois | Coût prévisible | +| **🆓 GRATUIT** | Kiro AI | 0 $ | Illimité | Claude 4.5 + GLM-5 + MiniMax gratuits | +| | OpenCode Free | 0 $ | Illimité | Sans authentification, modèles automatiques | +| | Vertex AI | 300 $ de crédit | Nouveaux comptes GCP | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Astuce pro :** Le combo RTK + Kiro AI + OpenCode Free = **0 $ de coût + 20 à 40 % d'économie de tokens** ! + +--- + +### 📊 Comprendre les coûts et la facturation de 9Router + +**La réalité de la facturation de 9Router :** + +✅ **Le logiciel 9Router = GRATUIT pour toujours** (open source, ne facture jamais) +✅ **Les "coûts" du tableau de bord = Affichage/suivi uniquement** (pas de vraies factures) +✅ **Vous payez les fournisseurs directement** (abonnements ou frais d'API) +✅ **Les fournisseurs GRATUITS restent GRATUITS** (iFlow, Kiro, Qwen = 0 $ illimité) +❌ **9Router n'envoie jamais de factures** et ne débite pas votre carte + +**Comment fonctionne l'affichage des coûts :** + +Le tableau de bord affiche des **coûts estimés** comme si vous utilisiez des API payantes directement. Ce n'est **pas une facturation** — c'est un outil de comparaison pour montrer vos économies. + +**Scénario d'exemple :** + +``` +Affichage du tableau de bord : +• Total des requêtes : 1 662 +• Total des tokens : 47M +• Coût affiché : 290 $ + +Vérification de la réalité : +• Fournisseur : iFlow (GRATUIT illimité) +• Paiement réel : 0,00 $ +• Ce que signifie 290 $ : Le montant que vous avez ÉCONOMISÉ en utilisant des modèles gratuits ! +``` + +**Règles de paiement :** + +- **Fournisseurs par abonnement** (Claude Code, Codex) : Payez-les directement via leurs sites web +- **Fournisseurs bon marché** (GLM, MiniMax) : Payez-les directement, 9Router se contente de router +- **Fournisseurs GRATUITS** (iFlow, Kiro, Qwen) : Vraiment gratuits pour toujours, sans frais cachés +- **9Router** : Ne facture jamais rien, jamais + +--- + +## 🎯 Cas d'utilisation + +### Cas 1 : "J'ai un abonnement Claude Pro" + +**Problème :** Le quota expire inutilisé, limites de débit pendant un codage intensif + +**Solution :** + +``` +Combo : "maximize-claude" + 1. cc/claude-opus-4-7 (utilisez l'abonnement à fond) + 2. glm/glm-5.1 (sauvegarde économique quand le quota est épuisé) + 3. kr/claude-sonnet-4.5 (repli gratuit d'urgence) + +Coût mensuel : 20 $ (abonnement) + ~5 $ (sauvegarde) = 25 $ au total +vs. 20 $ + buter sur les limites = frustration +``` + +### Cas 2 : "Je veux un coût zéro" + +**Problème :** Impossible de payer un abonnement, besoin d'un codage IA fiable + +**Solution :** + +``` +Combo : "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit illimité) + 2. kr/glm-5 (GLM-5 gratuit via Kiro) + 3. oc/ (OpenCode Free, sans authentification) + +Coût mensuel : 0 $ +Qualité : Modèles prêts pour la production + RTK économise 20 à 40 % de tokens +``` + +### Cas 3 : "J'ai besoin de coder 24h/24 et 7j/7, sans interruptions" + +**Problème :** Délais à respecter, pas de temps d'arrêt possible + +**Solution :** + +``` +Combo : "always-on" + 1. cc/claude-opus-4-7 (meilleure qualité) + 2. cx/gpt-5.5 (deuxième abonnement) + 3. glm/glm-5.1 (bon marché, réinitialisation quotidienne) + 4. minimax/MiniMax-M2.7 (le moins cher, réinitialisation 5 h) + 5. kr/claude-sonnet-4.5 (gratuit illimité) + +Résultat : 5 couches de repli = zéro temps d'arrêt +Coût mensuel : 20 à 200 $ (abonnements) + 10 à 20 $ (sauvegarde) +``` + +### Cas 4 : "Je veux une IA GRATUITE dans OpenClaw" + +**Problème :** Besoin d'un assistant IA dans les applications de messagerie (WhatsApp, Telegram, Slack...), entièrement gratuit + +**Solution :** + +``` +Combo : "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit) + 2. kr/glm-5 (GLM-5 gratuit) + 3. kr/MiniMax-M2.5 (MiniMax gratuit) + +Coût mensuel : 0 $ +Accès via : WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Questions fréquemment posées + +
+📊 Pourquoi mon tableau de bord affiche-t-il des coûts élevés ? + +Le tableau de bord suit votre utilisation de tokens et affiche des **coûts estimés** comme si vous utilisiez des API payantes directement. Ce n'est **pas une facturation réelle** — c'est une référence pour montrer combien vous économisez en utilisant des modèles gratuits ou des abonnements existants via 9Router. + +**Exemple :** + +- **Le tableau de bord affiche :** "290 $ de coût total" +- **La réalité :** Vous utilisez iFlow (GRATUIT illimité) +- **Votre coût réel :** **0,00 $** +- **Ce que signifie 290 $ :** Le montant que vous avez **économisé** en utilisant des modèles gratuits au lieu d'API payantes ! + +L'affichage des coûts est un "suivi d'économies" qui vous aide à comprendre vos habitudes d'utilisation et vos opportunités d'optimisation. + +
+ +
+💳 9Router me facturera-t-il ? + +**Non.** 9Router est un logiciel gratuit et open source qui s'exécute sur votre propre ordinateur. Il ne vous facture jamais rien. + +**Vous ne payez que :** + +- ✅ **Fournisseurs par abonnement** (Claude Code 20 $/mois, Codex 20 à 200 $/mois) → Payez-les directement sur leurs sites web +- ✅ **Fournisseurs bon marché** (GLM, MiniMax) → Payez-les directement, 9Router route simplement vos requêtes +- ❌ **9Router lui-même** → **Ne facture jamais rien, jamais** + +9Router est un proxy/routeur local. Il n'a pas votre carte bancaire, ne peut pas envoyer de factures et ne possède aucun système de facturation. C'est un logiciel entièrement gratuit. + +
+ +
+🆓 Les fournisseurs GRATUITS sont-ils vraiment illimités ? + +**Oui !** Les fournisseurs GRATUITS actuels (Kiro, OpenCode Free, Vertex) sont réellement gratuits et **sans frais cachés**. + +Ce sont des services gratuits offerts par ces entreprises respectives : + +- **Kiro AI** : Claude 4.5 + GLM-5 + MiniMax gratuits et illimités via AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free** : Proxy de passage sans authentification, modèles récupérés automatiquement depuis `opencode.ai/zen/v1/models` +- **Vertex AI** : 300 $ de crédit gratuit pour les nouveaux comptes Google Cloud (90 jours) + +9Router se contente de router vos requêtes vers eux — il n'y a pas de "piège" ni de facturation future. Ce sont de véritables services gratuits, et 9Router les rend faciles à utiliser avec le support du repli. + +**Niveaux gratuits interrompus (non recommandés) :** + +- ❌ **iFlow** : Était gratuit et illimité, désormais payant (2026) +- ❌ **Qwen Code** : Le niveau gratuit OAuth a été interrompu par Alibaba le 15/04/2026 +- ❌ **Gemini CLI** : Fonctionne toujours, mais l'utiliser avec des outils non-CLI (Claude, Codex, Cursor...) peut entraîner des bannissements de compte — utilisez-le uniquement si vous restez sur le Gemini CLI lui-même + +
+ +
+💰 Comment minimiser mes vrais coûts d'IA ? + +**Stratégie gratuit-d'abord :** + +1. **Commencez avec un combo 100 % gratuit :** + + ``` + 1. gc/gemini-3-flash (180K/mois gratuit de Google) + 2. if/kimi-k2-thinking (gratuit illimité d'iFlow) + 3. qw/qwen3-coder-plus (gratuit illimité de Qwen) + ``` + + **Coût : 0 $/mois** + +2. **Ajoutez une sauvegarde économique** uniquement si nécessaire : + + ``` + 4. glm/glm-4.7 (0,6 $/1M de tokens) + ``` + + **Coût supplémentaire : Vous ne payez que ce que vous utilisez réellement** + +3. **Utilisez les fournisseurs par abonnement en dernier :** + - Uniquement si vous les possédez déjà + - 9Router vous aide à maximiser leur valeur grâce au suivi de quota + +**Résultat :** La plupart des utilisateurs peuvent fonctionner à 0 $/mois en utilisant uniquement les niveaux gratuits ! + +
+ +
+📈 Et si mon utilisation explose soudainement ? + +Le repli intelligent de 9Router évite les frais surprises : + +**Scénario :** Vous êtes en sprint de codage et vous épuisez vos quotas + +**Sans 9Router :** + +- ❌ Limite de débit atteinte → Le travail s'arrête → Frustration +- ❌ Ou : Vous accumulez accidentellement d'énormes factures d'API + +**Avec 9Router :** + +- ✅ L'abonnement atteint sa limite → Repli automatique vers le niveau économique +- ✅ Le niveau économique devient coûteux → Repli automatique vers le niveau gratuit +- ✅ N'arrêtez jamais de coder → Coûts prévisibles + +**Vous gardez le contrôle :** Définissez des limites de dépenses par fournisseur dans le tableau de bord, et 9Router les respecte. + +
+ +--- + +## 📖 Guide d'installation + +
+🔐 Fournisseurs par abonnement (maximisez la valeur) + +### Claude Code (Pro/Max) + +```bash +Tableau de bord → Providers → Connectez Claude Code +→ Connexion OAuth → Renouvellement automatique des tokens +→ Suivi de quota de 5 heures + hebdomadaire + +Modèles : + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Astuce pro :** Utilisez Opus pour les tâches complexes et Sonnet pour la vitesse. 9Router suit le quota par modèle ! + +### OpenAI Codex (Plus/Pro) + +```bash +Tableau de bord → Providers → Connectez Codex +→ Connexion OAuth (port 1455) +→ Réinitialisation de 5 heures + hebdomadaire + +Modèles : + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Tableau de bord → Providers → Connectez GitHub +→ OAuth via GitHub +→ Réinitialisation mensuelle (le 1er du mois) + +Modèles : + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Tableau de bord → Providers → Connectez Cursor +→ Connexion OAuth +→ Abonnement mensuel + +Modèles : + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Fournisseurs bon marché (sauvegarde) + +### GLM-5.1 / GLM-4.7 (réinitialisation quotidienne, 0,6 $/1M) + +1. Inscrivez-vous : [Zhipu AI](https://open.bigmodel.cn/) +2. Obtenez la clé API depuis le Coding Plan +3. Tableau de bord → Add API Key : + - Fournisseur : `glm` + - API Key : `votre-clé` + +**Utilisation :** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Astuce pro :** Le Coding Plan offre un quota 3× pour 1/7 du coût ! Réinitialisation quotidienne à 10 h. + +### MiniMax M2.7 (réinitialisation 5 h, 0,20 $/1M) + +1. Inscrivez-vous : [MiniMax](https://www.minimax.io/) +2. Obtenez la clé API +3. Tableau de bord → Add API Key + +**Utilisation :** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Astuce pro :** L'option la moins chère pour les longs contextes (1M de tokens) ! + +### Kimi K2.5 (9 $/mois forfaitaires) + +1. Abonnez-vous : [Moonshot AI](https://platform.moonshot.ai/) +2. Obtenez la clé API +3. Tableau de bord → Add API Key + +**Utilisation :** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Astuce pro :** 9 $/mois forfaitaires pour 10M de tokens = coût effectif de 0,90 $/1M ! + +
+ +
+🆓 Fournisseurs GRATUITS (recommandés) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATUIT) + +```bash +Tableau de bord → Connectez Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google ou GitHub +→ Utilisation illimitée + +Modèles : + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Astuce pro :** La meilleure option gratuite pour Claude. Sans clé API, sans paiement, entièrement illimité. + +### OpenCode Free (sans authentification, modèles automatiques) + +```bash +Tableau de bord → Connectez OpenCode Free +→ Aucune connexion requise (proxy de passage) +→ Modèles récupérés automatiquement depuis opencode.ai/zen/v1/models +``` + +**Astuce pro :** La configuration la plus rapide. Connectez-vous et commencez à coder. + +### Vertex AI (300 $ de crédit gratuit pour les nouveaux comptes GCP) + +```bash +Tableau de bord → Connectez Vertex AI +→ Importez le JSON du compte de service Google Cloud +→ Activez l'API Vertex AI dans votre projet GCP + +Modèles : + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen via Vertex) : + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Astuce pro :** Les nouveaux comptes Google Cloud reçoivent 300 $ de crédit gratuit pendant 90 jours. Largement suffisant pour coder tous les jours. + +
+ +
+🎨 Créez des combos + +### Exemple 1 : Maximisez l'abonnement → Sauvegarde économique + +``` +Tableau de bord → Combos → Create New + +Nom : premium-coding +Modèles : + 1. cc/claude-opus-4-7 (Abonnement principal) + 2. glm/glm-5.1 (Sauvegarde économique, 0,6 $/1M) + 3. minimax/MiniMax-M2.7 (Repli le moins cher, 0,20 $/1M) + +Utilisation en CLI : premium-coding + +Exemple de coût mensuel (100M de tokens) : + 80M via Claude (abonnement) : 0 $ supplémentaire + 15M via GLM : 9 $ + 5M via MiniMax : 1 $ + Total : 10 $ + votre abonnement +``` + +### Exemple 2 : Gratuit uniquement (coût zéro) + +``` +Nom : free-combo +Modèles : + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit illimité) + 2. kr/glm-5 (GLM-5 gratuit via Kiro) + 3. vertex/gemini-3.1-pro-preview (300 $ de crédit gratuit) + +Coût : 0 $ pour toujours (+ 20 à 40 % d'économie de tokens via RTK) ! +``` + +
+ +
+🔧 Intégration CLI + +### Cursor IDE + +``` +Settings → Models → Advanced : + OpenAI API Base URL : http://localhost:20128/v1 + OpenAI API Key : [depuis le tableau de bord 9router] + Model : cc/claude-opus-4-7 +``` + +Ou utilisez le combo : `premium-coding` + +### Claude Code + +Modifiez `~/.claude/config.json` : + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "votre-clé-api-9router" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="votre-clé-api-9router" + +codex "votre prompt" +``` + +### OpenClaw + +**Option 1 — Tableau de bord (recommandée) :** + +``` +Tableau de bord → CLI Tools → OpenClaw → Sélectionnez le modèle → Appliquer +``` + +**Option 2 — Manuel :** Modifiez `~/.openclaw/openclaw.json` : + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Remarque :** OpenClaw ne fonctionne qu'avec un 9Router local. Utilisez `127.0.0.1` au lieu de `localhost` pour éviter les problèmes de résolution IPv6. + +### Cline / Continue / RooCode + +``` +Provider : OpenAI Compatible +Base URL : http://localhost:20128/v1 +API Key : [depuis le tableau de bord] +Model : cc/claude-opus-4-7 +``` + +
+ +
+🚀 Déploiement + +### Déploiement VPS + +```bash +# Clonez et installez +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configuration +export JWT_SECRET="votre-secret-sécurisé-changez-le" +export INITIAL_PASSWORD="votre-mot-de-passe" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Démarrage +npm run start + +# Ou utilisez PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Images publiées (multi-plateformes `linux/amd64` + `linux/arm64`) : + +- Docker Hub : [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR : [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Démarrage rapide (utilisez l'image publiée) :** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Ouvrez http://localhost:20128 + +**Compiler depuis le code source (dev) :** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Valeurs par défaut du conteneur :** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Commandes utiles :** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # mise à jour vers la dernière version +``` + +**Persistance des données :** `$HOME/.9router/db/data.sqlite` sur l'hôte ↔ `/app/data/db/data.sqlite` dans le conteneur. + +### Variables d'environnement + +| Variable | Par défaut | Description | +| ---------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | Généré automatiquement (`~/.9router/jwt-secret`) | Secret de signature JWT pour le cookie d'authentification du tableau de bord (remplacez-le pour partager entre instances) | +| `INITIAL_PASSWORD` | `123456` | Mot de passe de première connexion quand aucun hash n'est enregistré | +| `DATA_DIR` | `~/.9router` | Emplacement principal des données de l'app (SQLite dans `$DATA_DIR/db/data.sqlite`) | +| `PORT` | défaut du framework | Port du service (`20128` dans les exemples) | +| `HOSTNAME` | défaut du framework | Hôte de liaison (Docker utilise `0.0.0.0` par défaut) | +| `NODE_ENV` | défaut du runtime | Définissez `production` pour le déploiement | +| `BASE_URL` | `http://localhost:20128` | URL de base interne côté serveur utilisée par les tâches de synchronisation cloud | +| `CLOUD_URL` | `https://9router.com` | URL de base de l'endpoint de synchronisation cloud côté serveur | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL de base publique/rétrocompatible (préférez `BASE_URL` pour le runtime serveur) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL cloud publique/rétrocompatible (préférez `CLOUD_URL` pour le runtime serveur) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Secret HMAC pour les clés API générées | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Sel pour le hachage stable de l'ID machine | +| `ENABLE_REQUEST_LOGS` | `false` | Active les journaux de requêtes/réponses dans `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force le cookie d'authentification `Secure` (mettez `true` derrière un proxy inverse HTTPS) | +| `REQUIRE_API_KEY` | `false` | Applique la clé API Bearer sur les routes `/v1/*` (recommandé pour les déploiements exposés à Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vide | Proxy sortant optionnel pour les appels aux fournisseurs externes | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint du fournisseur de recherche web SearXNG intégré, sans authentification | + +Remarques : + +- Les variables de proxy en minuscules sont également prises en charge : `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` n'est pas intégré à l'image Docker (`.dockerignore`) ; injectez la configuration du runtime avec `--env-file` ou `-e`. +- Sous Windows, `APPDATA` peut être utilisé pour résoudre le chemin de stockage local. +- `INSTANCE_NAME` apparaît dans l'ancienne documentation/les anciens modèles d'environnement, mais n'est actuellement pas utilisé au runtime. + +### Fichiers d'exécution et stockage + +- État principal de l'app : `${DATA_DIR}/db/data.sqlite` (SQLite — fournisseurs, combos, alias, clés, paramètres, historique d'utilisation) +- Sauvegardes automatiques : `${DATA_DIR}/db/backups/` +- Journaux optionnels de requêtes/translator : `/logs/...` quand `ENABLE_REQUEST_LOGS=true` +- `${DATA_DIR}` et `~/.9router` résolvent tous deux le même emplacement dans un conteneur Docker — le lien symbolique `/root/.9router -> /app/data` est créé au moment de la compilation. + +
+ +--- + +## 📊 Modèles disponibles + +
+Afficher tous les modèles disponibles + +**Claude Code (`cc/`)** - Pro/Max : + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro : + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)** : + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Abonnement : + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - 0,6 $/1M : + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - 0,2 $/1M : + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - 9 $/mois forfaitaires : + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - GRATUIT illimité : + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - GRATUIT sans authentification : + +- Récupérés automatiquement depuis `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - 300 $ de crédit gratuit : + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Dépannage + +**"Language model did not provide messages"** + +- Quota du fournisseur épuisé → Consultez le suivi de quota du tableau de bord +- Solution : utilisez le repli du combo ou passez à un niveau moins cher + +**Limites de débit** + +- Quota d'abonnement épuisé → Repli vers GLM/MiniMax +- Ajoutez le combo : `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**Token OAuth expiré** + +- Renouvelé automatiquement par 9Router +- Si les problèmes persistent : Tableau de bord → Provider → Reconnecter + +**Coûts élevés** + +- Activez RTK dans Tableau de bord → Paramètres d'Endpoint (activé par défaut, économise 20 à 40 % de tokens) +- Consultez les statistiques d'utilisation dans le tableau de bord +- Changez le modèle principal pour GLM/MiniMax +- Utilisez le niveau gratuit (Kiro, OpenCode Free, Vertex) pour les tâches non critiques + +**Le tableau de bord s'ouvre sur le mauvais port** + +- Définissez `PORT=20128` et `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**La première connexion ne fonctionne pas** + +- Vérifiez `INITIAL_PASSWORD` dans `.env` +- S'il n'est pas défini, le mot de passe de secours est `123456` + +**Pas de journaux de requêtes dans `logs/`** + +- Définissez `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pile technologique + +- **Runtime** : Node.js 20+ +- **Framework** : Next.js 16 +- **UI** : React 19 + Tailwind CSS 4 +- **Base de données** : SQLite (better-sqlite3 / node:sqlite / sql.js en secours) +- **Streaming** : Server-Sent Events (SSE) +- **Authentification** : OAuth 2.0 (PKCE) + JWT + clés API + +--- + +## 📝 Référence de l'API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer votre-clé-api +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Écrivez une fonction pour..."} + ], + "stream": true +} +``` + +### Lister les modèles + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer votre-clé-api + +→ Renvoie tous les modèles et combos au format OpenAI +``` + +## 📧 Support + +- **Site web** : [9router.com](https://9router.com) +- **GitHub** : [github.com/decolua/9router](https://github.com/decolua/9router) +- **Problèmes** : [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Contributeurs + +Merci à tous les contributeurs qui ont contribué à améliorer 9Router ! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Graphique d'étoiles + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Un fork TypeScript complet de 9Router. Ajoute plus de 36 fournisseurs, un repli automatique à 4 niveaux, des API multimodales (images, embeddings, audio, TTS), un disjoncteur, un cache sémantique, des évaluations de LLM et un tableau de bord soigné. Plus de 368 tests unitaires. Disponible via npm et Docker. + +--- + +## 🙏 Remerciements + +Construit sur les épaules de géants : + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implémentation originale en Go qui a inspiré ce port JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — économiseur de tokens en Rust. 9Router porte son pipeline de compression en JS → **−20 à 40 % de tokens d'entrée** sur chaque requête. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) par **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapte son prompt → **−65 % de tokens de sortie**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) par **[@DietrichGebert](https://github.com/DietrichGebert)** — compétence de _"dev sénior fainéant"_. 9Router injecte sa hiérarchie YAGNI-d'abord → **moins de tokens, moins de code, des diffs plus courts**. + +Un immense merci à ces auteurs — sans leur travail, les fonctionnalités d'économie de tokens de 9Router n'existeraient pas. Mettez-leur une ⭐ sur GitHub ! + +--- + +## 📄 Licence + +Licence MIT - voir [LICENSE](LICENSE) pour plus de détails. + +--- + +
+ Conçu avec ❤️ pour les développeurs qui codent 24h/24 et 7j/7 +
+ diff --git a/i18n/README.id-ID.md b/i18n/README.id-ID.md new file mode 100644 index 00000000..dbfbd8f5 --- /dev/null +++ b/i18n/README.id-ID.md @@ -0,0 +1,951 @@ +
+ 9Router Dashboard + + # 9Router - Router AI Gratis + + **Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.** + + **Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com) + + [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md) +
+ +--- + +## 🤔 Kenapa 9Router? + +**Berhenti buang-buang uang dan terhambat limit:** + +- ❌ Kuota langganan hangus tiap bulan tanpa terpakai +- ❌ Rate limit bikin ngoding berhenti di tengah jalan +- ❌ API mahal ($20–50/bulan per provider) +- ❌ Harus gonta-ganti provider secara manual + +**9Router menyelesaikan itu semua:** + +- ✅ **Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset +- ✅ **Fallback otomatis** - langganan → murah → gratis, tanpa downtime +- ✅ **Multi-akun** - round-robin antar akun untuk tiap provider +- ✅ **Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun + +--- + +## 🔄 Cara Kerja + +``` +┌─────────────┐ +│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ kamu │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • Konversi format (OpenAI ↔ Claude) │ +│ • Pelacakan kuota │ +│ • Refresh token otomatis │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI + │ ↓ kuota habis + ├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ batas budget tercapai + └─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited) + +Hasil: ngoding tanpa berhenti, biaya minimum +``` + +--- + +## ⚡ Mulai Cepat + +**1. Install secara global:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard terbuka di `http://localhost:20128` + +**2. Hubungkan provider gratis (tanpa perlu daftar):** + +Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai! + +**3. Pakai di tool CLI kamu:** + +``` +Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [salin dari dashboard] + Model: if/kimi-k2-thinking +``` + +**Cuma itu!** Mulai ngoding dengan model AI gratis. + +**Alternatif: jalankan dari source (repo ini):** + +Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode produksi: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL default: +- Dashboard: `http://localhost:20128/dashboard` +- API kompatibel OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Video Tutorial + +
+ +### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis + +[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0) + +**🎬 Tonton tutorial langkah demi langkah:** +- ✅ Install dan setup 9Router +- ✅ Konfigurasi Claude Sonnet 4.5 gratis +- ✅ Integrasi dengan Claude Code +- ✅ Demo live coding + +**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community + +[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg) + +
+ +--- + +## 🛠️ Tool CLI yang Didukung + +9Router bekerja mulus dengan semua tool AI coding utama: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Provider yang Didukung + +### 🔐 Provider OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 Provider Gratis + +
+ + + + + + + +
+ iFlow
+ iFlow AI
+ 8+ model • unlimited +
+ Qwen
+ Qwen Code
+ 3+ model • unlimited +
+ Gemini CLI
+ Gemini CLI
+ 180 ribu request/bulan gratis +
+ Kiro
+ Kiro AI
+ Claude • unlimited +
+
+ +### 🔑 Provider API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic

+
+ +--- + +## 💡 Fitur Utama + +| Fitur | Ringkasan | Manfaat | +|-------|-----------|---------| +| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime | +| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal | +| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun | +| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi | +| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual | +| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu | +| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah | +| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun | +| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran | +| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel | + +
+📖 Detail Fitur + +### 🎯 Smart Fallback 3 Tingkat + +Buat combo dengan fallback otomatis: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (langganan) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. if/kimi-k2-thinking (fallback gratis) + +→ Otomatis beralih saat kuota habis atau terjadi error +``` + +### 📊 Pelacakan Kuota Real-time + +- Konsumsi token per provider +- Hitung mundur reset (5 jam, harian, mingguan) +- Estimasi biaya untuk tier berbayar +- Laporan pengeluaran bulanan + +### 🔄 Konversi Format + +Konversi mulus antar format: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya +- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint + +### 👥 Dukungan Multi-akun + +- Tambahkan beberapa akun per provider +- Round-robin otomatis atau routing berbasis prioritas +- Saat satu akun mencapai kuota, fallback ke akun berikutnya + +### 🔄 Auto Refresh Token + +- Token OAuth di-refresh otomatis sebelum kedaluwarsa +- Tidak perlu autentikasi ulang manual +- Pengalaman mulus di semua provider + +### 🎨 Combo Kustom + +- Buat kombinasi model tanpa batas +- Campur tier langganan, murah, dan gratis +- Beri nama combo agar mudah diakses +- Bagikan combo antar perangkat lewat cloud sync + +### 📝 Log Request + +- Log lengkap request/response dalam mode debug +- Lacak API call, header, dan payload +- Troubleshoot masalah integrasi +- Ekspor log untuk dianalisis + +### 💾 Cloud Sync + +- Sinkronkan provider, combo, dan pengaturan antar perangkat +- Sinkronisasi latar belakang otomatis +- Penyimpanan terenkripsi yang aman +- Akses setup dari mana saja + +#### Catatan tentang cloud runtime + +- Untuk produksi, disarankan memakai variabel cloud sisi server: + - `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi) + - `CLOUD_URL` (base URL endpoint cloud sync) +- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`. +- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia. + +### 📊 Analitik Penggunaan + +- Lacak pemakaian token per provider dan per model +- Estimasi biaya dan tren pengeluaran +- Laporan dan insight bulanan +- Optimalkan pengeluaran AI + +> **💡 PENTING - tentang biaya di dashboard:** +> +> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**. +> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar. +> +> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow, +> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas). +> +> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router! + +### 🌐 Deploy di Mana Saja + +- 💻 **Localhost** - default, jalan offline +- ☁️ **VPS/Cloud** - berbagi antar perangkat +- 🐳 **Docker** - deploy satu perintah +- 🚀 **Cloudflare Workers** - jaringan edge global + +
+ +--- + +## 💰 Ringkasan Harga + +| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk | +|------|----------|-------|-------------|-------------| +| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan | +| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI | +| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! | +| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub | +| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat | +| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah | +| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi | +| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis | +| | Qwen | $0 | Unlimited | 3 model gratis | +| | Kiro | $0 | Unlimited | Claude gratis | + +**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0! + +--- + +### 📊 Tentang Biaya dan Penagihan 9Router + +**Fakta soal penagihan 9Router:** + +✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan) +✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan) +✅ **Pembayaran langsung ke provider** (langganan atau biaya API) +✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router tidak mengirim invoice** atau menagih kartumu + +**Cara kerja tampilan biaya:** + +Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu. + +**Contoh skenario:** +``` +Tampilan dashboard: +• Total request: 1.662 +• Total token: 47 juta +• Biaya tertampil: $290 + +Kenyataannya: +• Provider: iFlow (gratis unlimited) +• Yang benar-benar dibayar: $0.00 +• Arti $290: jumlah yang kamu hemat dengan memakai model gratis! +``` + +**Aturan pembayaran:** +- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing +- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing +- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi +- **9Router**: tidak menagih apa pun + +--- + +## 🎯 Studi Kasus + +### Kasus 1: "Saya punya langganan Claude Pro" + +**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat + +**Solusi:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin) + 2. glm/glm-4.7 (backup murah saat kuota habis) + 3. if/kimi-k2-thinking (fallback darurat gratis) + +Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25 +vs. $20 + kena limit = frustrasi +``` + +### Kasus 2: "Saya mau biaya nol" + +**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal + +**Solusi:** +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + +Biaya bulanan: $0 +Kualitas: model siap produksi +``` + +### Kasus 3: "Ngoding 24/7 tanpa terputus" + +**Masalah:** deadline mepet, downtime tidak dapat ditoleransi + +**Solusi:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (kualitas terbaik) + 2. cx/gpt-5.2-codex (langganan kedua) + 3. glm/glm-4.7 (murah, reset harian) + 4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam) + 5. if/kimi-k2-thinking (gratis unlimited) + +Hasil: 5 lapis fallback = zero downtime +Biaya bulanan: $20-200 (langganan) + $10-20 (backup) +``` + +### Kasus 4: "Saya mau pakai AI gratis di OpenClaw" + +**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis + +**Solusi:** +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (gratis unlimited) + 2. if/minimax-m2.1 (gratis unlimited) + 3. if/kimi-k2-thinking (gratis unlimited) + +Biaya bulanan: $0 +Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ FAQ + +
+📊 Kenapa dashboard menampilkan biaya yang besar? + +Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router. + +**Contoh:** +- **Tampilan dashboard:** "Total biaya $290" +- **Kenyataan:** sedang memakai iFlow (gratis unlimited) +- **Biaya sebenarnya:** **$0.00** +- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar! + +Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi. + +
+ +
+💳 Apakah 9Router menagih saya? + +**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali. + +**Kamu membayar ke:** +- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing +- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request +- ❌ **9Router sendiri** → **tidak menagih apa pun** + +9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis. + +
+ +
+🆓 Apakah provider gratis benar-benar unlimited? + +**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**. + +Ini adalah layanan gratis yang disediakan masing-masing perusahaan: +- **iFlow**: akses gratis unlimited ke 8+ model via OAuth +- **Kiro**: model Claude gratis unlimited via AWS Builder ID +- **Qwen**: akses gratis unlimited ke model Qwen via device authentication + +9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback. + +**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router. + +
+ +
+💰 Bagaimana cara menekan biaya AI seminimal mungkin? + +**Strategi free-first:** + +1. **Mulai dari combo 100% gratis:** + ``` + 1. gc/gemini-3-flash (180rb/bulan gratis dari Google) + 2. if/kimi-k2-thinking (gratis unlimited dari iFlow) + 3. qw/qwen3-coder-plus (gratis unlimited dari Qwen) + ``` + **Biaya: $0/bulan** + +2. **Tambahkan backup murah hanya bila perlu:** + ``` + 4. glm/glm-4.7 ($0.6 per 1 juta token) + ``` + **Tambahan biaya: bayar sesuai pemakaian saja** + +3. **Gunakan provider langganan paling akhir:** + - Hanya jika kamu memang sudah punya + - 9Router memaksimalkan nilainya lewat pelacakan kuota + +**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis! + +
+ +
+📈 Bagaimana kalau pemakaian tiba-tiba melonjak? + +Smart fallback 9Router mencegah tagihan tak terduga: + +**Skenario:** kuota habis di tengah sprint coding + +**Tanpa 9Router:** +- ❌ Kena rate limit → kerja berhenti → frustrasi +- ❌ Atau: tagihan API mahal tanpa disengaja + +**Dengan 9Router:** +- ✅ Langganan mencapai batas → otomatis fallback ke tier murah +- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis +- ✅ Ngoding tidak berhenti → biaya tetap terprediksi + +**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya. + +
+ +--- + +## 📖 Panduan Setup + +
+🔐 Provider Langganan (maksimalkan nilainya) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → hubungkan Claude Code +→ login OAuth → refresh token otomatis +→ pelacakan kuota 5 jam + mingguan + +Model: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → hubungkan Codex +→ login OAuth (port 1455) +→ reset 5 jam + mingguan + +Model: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (180rb request/bulan gratis!) + +```bash +Dashboard → Providers → hubungkan Gemini CLI +→ Google OAuth +→ 180rb/bulan + 1rb/hari + +Model: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar. + +### GitHub Copilot + +```bash +Dashboard → Providers → hubungkan GitHub +→ OAuth via GitHub +→ reset bulanan (tanggal 1 tiap bulan) + +Model: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Provider Murah (backup) + +### GLM-4.7 (reset harian, $0.6/1M) + +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Ambil API key dari Coding Plan +3. Dashboard → tambahkan API key: + - Provider: `glm` + - API Key: `your-key` + +**Pemakaian:** `glm/glm-4.7` + +**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00. + +### MiniMax M2.1 (reset 5 jam, $0.20/1M) + +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `minimax/MiniMax-M2.1` + +**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)! + +### Kimi K2 ($9/bulan flat) + +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `kimi/kimi-latest` + +**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M! + +
+ +
+🆓 Provider Gratis (backup darurat) + +### iFlow (8 model gratis) + +```bash +Dashboard → hubungkan iFlow +→ login OAuth iFlow +→ pemakaian unlimited + +Model: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 model gratis) + +```bash +Dashboard → hubungkan Qwen +→ autentikasi device code +→ pemakaian unlimited + +Model: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (Claude gratis) + +```bash +Dashboard → hubungkan Kiro +→ AWS Builder ID atau Google/GitHub +→ pemakaian unlimited + +Model: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Membuat Combo + +### Contoh 1: maksimalkan langganan → backup murah + +``` +Dashboard → Combos → buat baru + +Nama: premium-coding +Model: + 1. cc/claude-opus-4-6 (langganan, utama) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M) + +Pemakaian di CLI: premium-coding + +Contoh biaya bulanan (100 juta token): + 80 juta lewat Claude (langganan): tambahan $0 + 15 juta lewat GLM: $9 + 5 juta lewat MiniMax: $1 + Total: $10 +``` + +### Contoh 2: combo 100% gratis + +``` +Nama: free-forever +Model: + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + 4. kr/claude-sonnet-4.5 (gratis unlimited) + +Biaya bulanan: $0 +``` + +### Tips membuat combo + +- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah +- Selalu taruh minimal satu provider gratis di posisi terakhir +- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI +- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain + +
+ +--- + +## 🐳 Deployment + +
+Docker + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v 9router-data:/app/data \ + -e PORT=20128 \ + -e BASE_URL=http://localhost:20128 \ + ghcr.io/decolua/9router:latest +``` + +Dashboard: `http://localhost:20128/dashboard` + +
+ +
+VPS / Cloud + +```bash +npm install -g 9router +PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router +``` + +Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri. + +
+ +
+Cloudflare Workers + +```bash +npm run build +npx wrangler deploy +``` + +Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare. + +
+ +--- + +## 🧪 Troubleshooting + +| Masalah | Kemungkinan Penyebab | Solusi | +|---------|----------------------|--------| +| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` | +| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard | +| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` | +| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard | +| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) | +| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast | + +Aktifkan mode debug di dashboard untuk melihat log lengkap request/response. + +--- + +## 🤝 Kontribusi + +Kontribusi sangat diterima! + +1. Fork repo ini +2. Buat branch fitur (`git checkout -b feature/nama-fitur`) +3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`) +4. Push ke branch (`git push origin feature/nama-fitur`) +5. Buka Pull Request + +--- + +## 📄 Lisensi + +MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya. + +--- + +
+ +**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!** + +[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues) + +
diff --git a/i18n/README.pt-BR.md b/i18n/README.pt-BR.md new file mode 100644 index 00000000..c141d679 --- /dev/null +++ b/i18n/README.pt-BR.md @@ -0,0 +1,1526 @@ +
+ Painel do 9Router + + # 9Router — roteador de IA GRATUITO e economizador de tokens + + **Nunca pare de codificar. Economize 20-40% de tokens com RTK + fallback automático para modelos de IA GRATUITOS e baratos.** + + **Conecte todas as ferramentas de código de IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) a mais de 40 provedores de IA e mais de 100 modelos.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![Licença](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Início rápido](#-início-rápido) • [💡 Recursos](#-principais-recursos) • [📖 Configuração](#-guia-de-configuração) • [🌐 Site](https://9router.com) + +[🇺🇸 English](../README.md) • [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) + +
+ +--- + +## 🤔 Por que 9Router? + +**Pare de desperdiçar dinheiro, tokens e atingir limites:** + +- ❌ A cota de assinatura expira sem ser utilizada todos os meses +- ❌ Os limites de requisições interrompem você durante a programação +- ❌ Saídas de ferramentas (`git diff`, `grep`, `ls`...) consomem tokens rapidamente +- ❌ APIs caras ($20–50/mês por provedor) +- ❌ Troca manual entre provedores + +**9Router resolve isso:** + +- ✅ **RTK Token Saver** - Compactação automática do conteúdo de `tool_result`, economize 20-40% de tokens por solicitação +- ✅ **Maximize as assinaturas** - Rastreie a cota, aproveite toda a cota antes da renovação +- ✅ **Auto fallback** - Assinatura → Econômico → Gratuito, sem tempo de inatividade +- ✅ **Múltiplas contas** - Round-robin entre contas por provedor +- ✅ **Universal** - Funciona com Claude Code, Codex, Cursor, Cline, qualquer ferramenta CLI + +--- + +## 🔄 Como funciona + +``` +┌─────────────┐ +│ Sua CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Ferramenta│ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Roteador inteligente) │ +│ • RTK Token Saver (reduz tokens de `tool_result`) │ +│ • Tradução de formatos (OpenAI ↔ Claude) │ +│ • Acompanhamento de cotas │ +│ • Renovação automática de token │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Nível 1: ASSINATURA] Claude Code, Codex, GitHub Copilot + │ ↓ cota esgotada + ├─→ [Nível 2: ECONÔMICO] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ limite de orçamento + └─→ [Nível 3: GRATUITO] Kiro, OpenCode Free, Vertex ($300 em créditos) + +Resultado: programe sem interrupções, com custo mínimo e economia de 20% a 40% dos tokens via RTK +``` + +--- + +## ⚡ Início rápido + +**1. Instale globalmente:** + +```bash +npm install -g 9router +9router +``` + +🎉 O painel abre em `http://localhost:20128` + +**2. Conecte um provedor GRATUITO (sem necessidade de inscrição):** + +Painel → Provedores → Conecte **Kiro AI** (~50 créditos/mês grátis: Claude 4.5 + GLM-5 + MiniMax) ou **OpenCode Free** (sem autenticação) → Pronto! + +**3. Use em sua ferramenta CLI:** + +``` +Configurações do Claude Code/Codex/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [copie do painel] + Model: kr/claude-sonnet-4.5 +``` + +**É isso aí!** Comece a codificar com modelos de IA GRATUITOS. + +**Alternativa: executar a partir do código-fonte (este repositório):** + +Este pacote de repositório é privado (`9router-app`), portanto, a execução pelo código-fonte/Docker é o caminho de desenvolvimento local esperado. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Modo de produção: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URLs padrão: + +- Painel: `http://localhost:20128/dashboard` +- API compatível com OpenAI: `http://localhost:20128/v1` + +--- + +## Guias de vídeo + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw no 9Router
por Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code Configuração ilimitada GRATUITA
por Construir IA com Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 Inglês
+ 9Router + Claude Code Configuração GRATUITA
por Construir IA com Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 Inglês
+ 9Router + Claude Code Configuração GRATUITA
por Construir IA com Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 Inglês
+ Claude Code FREE Forever - Modelos ilimitados
por Construa IA com Hamid
+
+ +Claude CLI Free Setup +
+ 🇺🇸 Inglês
+ Configuração gratuita Claude CLI com 9Router 🚀
por CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
por Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 Inglês
+ FREE OpenClaw + Claude Opus 4.6
por Construir IA com Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonésia
+Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Configuração Rápida 9Router 🚀
por Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonésia
+ Cara Implante 9Router no Hugging Face GRATUITAMENTE sem parar! | Alternativa VPS RAM 16GB
por Krisswuh
+
+ + این شکلی از هر API ای استفاده کن برای هوش مصنوعی +
+ 🇮🇷 Persa-فارسی
+ این شکلی از هر API ای استفاده کن برای هوش مصنوعی
por Matin SenPai
+
+ + Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z +
+ 🇻🇳 Tiếng Việt
+ Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z
por tuanminhhole
+
+ +
+ +> 🎬 **Fez um vídeo sobre o 9Router?** Envie um [Pull Request](https://github.com/decolua/9router/pulls) adicionando seu vídeo a esta seção - nós o mesclaremos! + +--- + +## 🛠️ Ferramentas CLI suportadas + +9Router funciona perfeitamente com todas as principais ferramentas de codificação de IA: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Claude Code
+ Código-Claude +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ antigravidade +
+ Cline
+ Cline +
+ Continue
+ Continuar +
+Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+Kilo Code
+ Código Kilo +
+ OpenDesign
+ OpenDesign +
+ jcode
+ jcódigo +
+ Grok Build
+ Grok Build +
+ Devin CLI
+ Devin CLI +
+ DeepSeek TUI
+ DeepSeek TUI +
+ Qwen Code
+ Código Qwen +
+
+ +--- + +## 🌐 Provedores compatíveis + +### 🔐 Provedores OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Código-Claude +
+ Antigravity
+ antigravidade +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Provedores gratuitos + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
50 créditos/mês grátis
+
+ OpenCode Free
+ OpenCode grátis
+ Sem autenticação • Modelos de busca automática
Grátis (a lista de modelos varia)
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 créditos grátis
+
+
+ +> **Observação:** Os níveis gratuitos iFlow, Qwen Code e Gemini CLI foram descontinuados em 2026. Em vez disso, use Kiro / OpenCode Free / Vertex. +> +> **Kiro AI** mudou para um modelo pago em setembro de 2025 — o nível gratuito agora é limitado a **50 créditos/mês** (mais 500 créditos de avaliação para novas contas nos primeiros 30 dias). Camadas pagas: Pro $20/mo (1.000 créditos), Pro+ $40/mo (2.000), Pro Max $100/mo (5.000), Power $200/mo (10.000). +> A lista de modelos **OpenCode Free** varia com o tempo (alguns modelos são gratuitos apenas para promoções limitadas) — sujeito a alterações sem aviso prévio. +> **Vertex AI**: o crédito gratuito $300 para novas contas do GCP ainda é válido, mas desde março de 2026 o **endpoint da API Gemini não consome mais esses créditos**. Em vez disso, chame o endpoint **Vertex AI Studio**. + +### 🔑 Provedores de chaves de API (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Antrópico +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexidade +
+ Together
+ Juntos AI +
+ Fireworks
+ Fogos de artifício +
+ Cerebras
+Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+SiliconFlow +
+

...e mais de 20 provedores, incluindo Nebius, Chutes, Hyperbolic e endpoints personalizados compatíveis com OpenAI/Anthropic

+
+ +### 🏠 Provedores auto-hospedados + +Para fala e incorporações veiculadas em **sua própria** máquina — whisper.cpp, +faster-whisper, Speaches, Kokoro-FastAPI, openai-speech, llama.cpp/llama-server, +vLLM, Infinity, text-embeddings-inference ou qualquer outro serviço compatível com o formato OpenAI. + +| Provedor | Ponto final usado | Servidor típico | +| --- | --- | --- | +| **STT auto-hospedado** | `/v1/audio/transcriptions` | whisper.cpp, faster-whisper | +| **TTS auto-hospedado** | `/v1/audio/speech` | Kokoro-FastAPI, openedai-speech | +| **Incorporação auto-hospedada** | `/v1/embeddings` | llama-server, vLLM, Infinity | + +Todos os outros provedores de voz são um serviço de nuvem nomeado com um endpoint fixo. Estes +três leem seus endereços de **cada conexão**, para que um provedor possa apontar para +várias máquinas e balanceamento de carga entre elas como qualquer outra. + +Defina-o na conexão como `providerSpecificData.baseUrl`: + +| Provedor | Informe | Resultado | +| --- | --- | --- | +| STT auto-hospedado | o URL completo — `http://host:8080/v1/audio/transcriptions` | usado como está | +| TTS auto-hospedado | a raiz do servidor — `http://host:8880` | `+ /v1/audio/speech` | +| Incorporação auto-hospedada | a **base OpenAI**, `/v1` incluída — `http://host:8080/v1` | `+ /embeddings` | + +> **Cuidado com `/v1` em embeddings.** O adaptador anexa `/embeddings`, então +> `http://host:8080` resolve para `http://host:8080/embeddings` e perde o +> rota OpenAI – o llama-server responde **501**. Dê a ele o mesmo URL base de um OpenAI +> o cliente usaria. Um `.../v1/embeddings` completo também é aceito, então um valor colado +> de um exemplo `curl` também funciona. + +A chave API não é verificada pela maioria dos servidores locais, mas o campo não deve estar vazio: +é o que dá à conexão um registro de credenciais, e `baseUrl` reside lá. +Qualquer espaço reservado funciona. + +A incorporação auto-hospedada **não tem fallback na nuvem por design** — uma conexão salva +sem um `baseUrl` é relatado como um erro de configuração, em vez de silenciosamente +voltando para `api.openai.com`, que enviaria seu texto de entrada e chave de API para +terceiros por meio de um provedor chamado "Auto-hospedado". + +--- + +## 💡 Principais recursos + +| Recurso | O que faz | Por que é importante | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compactar saídas de ferramentas (`git diff`, `grep`, `ls`, `tree`...) antes de enviar para LLM | Economize **20-40% de tokens de entrada** por solicitação | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy `/v1/compress` externo opcional antes do roteamento do provedor | Economize mais tokens de contexto sem alterar clientes | +| 🪨 **Modo Caveman** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Injetar prompt de fala do homem das cavernas → Respostas do LLM concisas, substância técnica preservada | Economize **até 65% de tokens de produção** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Injetar prompt "lazy senior dev" → LLM escreve código mínimo YAGNI primeiro (Lite/Full/Ultra) | **Menos tokens de saída, menos refatoração** | +| 🎯 **Fallback inteligente de 3 camadas** | Rota automática: Assinatura → Barato → Grátis | Nunca pare de codificar, tempo de inatividade zero | +| 📊 **Acompanhamento de cotas em tempo real** | Contagem de tokens ao vivo + contagem regressiva de redefinição | Maximizar o valor da assinatura | +| 🔄 **Tradução de formato** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Funciona com qualquer ferramenta CLI | +| 👥 **Suporte para múltiplas contas** | Várias contas por provedor | Balanceamento de carga + redundância | +| 🔄 **Atualização automática de token** | Os tokens OAuth são atualizados automaticamente | Não é necessário fazer novo login manual | +| 🎨 **Combos Personalizados** | Crie combinações ilimitadas de modelos | Adapte o fallback às suas necessidades | +| 📝 **Registro de requisições** | Modo de depuração com logs completos de solicitação/resposta | Solucione problemas facilmente | +| 💾 **Sincronização na nuvem** | Sincronizar configuração entre dispositivos | Mesma configuração em todos os lugares | +| 📊 **Análise de uso** | Acompanhe tokens, custos e tendências ao longo do tempo | Otimizar gastos | +| 🌐 **Implante em qualquer lugar** | Localhost, VPS, Docker, Cloudflare Workers | Opções flexíveis de implantação | + +Configure `X-9Router-Token-Saver: off` para ignorar todos os economizadores de tokens para uma solicitação de chat. + +
+📖 Detalhes do recurso + +### 🚀 RTK Token Saver + +As saídas da ferramenta (`git diff`, `grep`, `find`, `ls`, `tree`, despejos de log...) geralmente consomem de 30 a 50% do seu orçamento de contexto. O RTK os detecta e aplica compactação inteligente e sem perdas **antes** que a solicitação chegue ao LLM: + +- **Filtros:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Detecção automática:** Não é necessária configuração — o RTK inspeciona o primeiro 1 KB de cada `tool_result` e escolhe o filtro correto. +- **Seguro por design:** Se um filtro falhar, gerar uma exceção ou aumentar a saída, o RTK mantém silenciosamente o texto original. Erros nunca interrompem sua solicitação. +- **Universal:** Funciona em todos os formatos (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) porque é executado **antes** de qualquer tradução de formato. +- **Padrão ATIVADO:** Alterne a qualquer momento em Painel → Configurações de endpoint. + +``` +Sem RTK: 47 mil tokens enviados ao LLM +Com RTK: 28 mil tokens enviados ao LLM (40% de economia · mesmo contexto · mesma resposta) +``` + +### 🧠 Headroom Token Saver + +O headroom é opcional e funciona separadamente. 9Router chama o endpoint `/v1/compress` local do Headroom e, em seguida, mantém roteamento normal, fallback, autenticação e rastreamento de uso: + +``` +Cliente → 9Router → Headroom /v1/compress → 9Router → provedor +``` + +Configuração local: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Habilite em Painel → Endpoint → Economia de tokens → Headroom. URL padrão: `http://localhost:8787`. + +Exemplos de Docker: + +```bash +# Serviço Headroom na mesma rede Docker +http://headroom:8787 + +# Headroom em execução na máquina host +http://host.docker.internal:8787 +``` + +Se o Headroom estiver inativo ou retornar um erro, o o 9Router seguirá em modo aberto e enviará a solicitação original. + +### 🐴 Ponytail (desenvolvedor sênior preguiçoso) + +O Ponytail injeta um prompt do sistema _"lazy senior dev"_ em cada solicitação, direcionando o LLM para o código mínimo, primeiro YAGNI - preferindo exclusão a adição, stdlib sobre novos deps, one-liners sobre abstrações. Adaptado de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construa o que for solicitado, nomeie a alternativa mais preguiçosa. +- **Full** — escada YAGNI aplicada: stdlib → nativo → dependências existentes → one-liner → código mínimo. +- **Ultra** — Extremista YAGNI: exclua primeiro, envie a linha única, desafie o restante do requisito na mesma resposta. + +``` +Sem Ponytail: código verboso, abstrações extras e estrutura "por precaução" +Com Ponytail: menor diff funcional, sem abstrações não solicitadas e menos tokens +``` + +Nunca negocie: validação de entrada, tratamento de erros que evita perda de dados, segurança, acessibilidade ou qualquer coisa explicitamente solicitada. Ative em Painel → Endpoint → Ponytail. Pode ser combinado com Caveman (concisão de saída) e RTK (compressão de entrada). + +### 🎯 Fallback inteligente de 3 camadas + +Crie combos com fallback automático: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (sua assinatura) + 2. glm/glm-4.7 (backup econômico, $0,60/1M) + 3. if/kimi-k2-thinking (fallback gratuito) + +→ Alterna automaticamente quando a cota acaba ou ocorre um erro +``` + +### 📊 Rastreamento de cotas em tempo real + +- Consumo de token por provedor +- Redefinir contagem regressiva (5 horas, diariamente, semanalmente) +- Estimativa de custos para níveis pagos +- Relatórios mensais de gastos + +### 🔄 Tradução de formato + +Tradução perfeita entre formatos: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Sua ferramenta CLI envia formato OpenAI → 9Router traduz → Provedor recebe formato nativo +- Funciona com qualquer ferramenta que suporte endpoints OpenAI personalizados + +### 👥 Suporte para múltiplas contas + +- Adicione várias contas por provedor +- Round-robin automático ou roteamento baseado em prioridade +- Fallback para a próxima conta quando uma delas atingir a cota + +### 🔄 Atualização automática de token + +- Os tokens OAuth são atualizados automaticamente antes da expiração +- Não é necessária reautenticação manual +- Experiência perfeita em todos os provedores + +### 🎨 Combos Personalizados + +- Crie combinações ilimitadas de modelos +- Combine níveis de assinatura, baratos e gratuitos +- Nomeie seus combos para facilitar o acesso +- Compartilhe combos entre dispositivos com Cloud Sync + +### 📝 Registro de requisições + +- Habilite o modo de depuração para logs completos de solicitação/resposta +- Rastreie chamadas de API, cabeçalhos e payloads +- Solucionar problemas de integração +- Exportar logs para análise + +### 💾 Sincronização na nuvem + +- Sincronize provedores, combos e configurações entre dispositivos +- Sincronização automática em segundo plano +- Armazenamento criptografado seguro +- Acesse sua configuração de qualquer lugar + +#### Notas de tempo de execução na nuvem + +- Prefira variáveis de nuvem do lado do servidor na produção: + - `BASE_URL` (URL interna de callback usado pelo agendador de sincronização) + - `CLOUD_URL` (base de endpoint de sincronização em nuvem) +- `NEXT_PUBLIC_BASE_URL` e `NEXT_PUBLIC_CLOUD_URL` ainda são suportados para compatibilidade/UI, mas o tempo de execução do servidor agora prioriza `BASE_URL`/`CLOUD_URL`. +- As solicitações de sincronização na nuvem agora usam o comportamento de tempo limite + falha rápida para evitar o travamento da interface do usuário quando o DNS/rede da nuvem estiver indisponível. + +### 📊 Análise de uso + +- Rastreie o uso de token por provedor e modelo +- Estimativa de custos e tendências de gastos +- Relatórios e insights mensais +- Otimize seus gastos com IA + +> **💡 IMPORTANTE - Compreendendo os custos do painel:** +> +> O "custo" exibido no Usage Analytics é **apenas para fins de rastreamento e comparação**. +> O próprio 9Router **nunca cobra** nada. Você paga apenas aos provedores diretamente (se usar serviços pagos). +> +> **Exemplo:** Se o seu painel mostrar "Custo total $290" ao usar modelos gratuitos do Kiro, isso representa +> o que você pagaria usando APIs pagas diretamente. Seu custo real = **$0** (nível gratuito do Kiro: ~50 créditos/mês). +> +> Pense nisso como um "rastreador de economia" que mostra quanto você está economizando usando modelos gratuitos ou +> roteamento através do 9Router! + +### 🌐 Implante em qualquer lugar + +- 💻 **Localhost** - Padrão, funciona offline +- ☁️ **VPS/Cloud** - Compartilhe entre dispositivos +- 🐳 **Docker** – Implantação com um comando +- 🚀 **Cloudflare Workers** - Rede global de edge + +
+ +--- + +## 💰 Visão geral dos preços + +| Nível | Provedor | Custo | Redefinição de cota | Melhor para | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 ECONOMIA DE TOKEN** | **RTK (integrado)** | **GRÁTIS** | Sempre ligado | **Economize 20-40% de tokens em CADA solicitação** | +| **💳 ASSINATURA** | Claude Code (Pro/Max) | $20-200/mo | 5h + semanalmente | Já inscrito | +| | Codex (Plus/Pro) | $20-200/mo | 5h + semanalmente | Usuários OpenAI | +| | GitHub Copilot | $10-19/mo | Mensalmente | Usuários do GitHub | +| | Cursor IDE | $20/mo | Mensalmente | Usuários de cursor | +| **💰 BARATO** | GLM-5.1/GLM-4.7 | $0.6/1M | Diariamente 10h | Backup de orçamento | +| | MiniMax M2.7 | $0.2/1M | Rolamento de 5 horas | Opção mais barata | +| | Kimi K2.5 | $9/mo plano | 10 milhões de tokens/mês | Custo previsível | + | **🆓 GRÁTIS** | Kiro AI | $0 | 50 créditos/mês | Claude 4.5 + GLM-5 + MiniMax grátis (níveis pagos acima) | + | | OpenCode Free | $0 | Varia* | Sem autenticação, modelos de busca automática (a lista muda ao longo do tempo) | +| | Vertex AI | Créditos $300 | Novas contas do GCP | Gemini 3 Pro + DeepSeek + GLM-5 (use o endpoint Vertex AI Studio para obter créditos gratuitos) | + +**💡 Dica profissional:** combinação RTK + Kiro AI + OpenCode grátis = **custo $0 + economia de token de 20-40%**! + +--- + +### 📊 Compreendendo os custos e faturamento do 9Router + +**Como funciona o faturamento do 9Router:** + +✅ **Software 9Router = GRATUITO para sempre** (código aberto, nunca cobra) +✅ **Painel "custos" = Somente exibição/rastreamento** (não são cobranças reais) +✅ **Você paga diretamente aos provedores** (assinaturas ou taxas de API) +✅ **Provedores GRATUITOS permanecem GRATUITOS** (Kiro ~50 créditos/mês, OpenCode Free, créditos do Vertex $300 = $0 dentro dos limites do nível gratuito) — observe que os níveis gratuitos do iFlow/Qwen/Gemini CLI foram descontinuados em 2026 +❌ **9Router nunca envia faturas** ou cobra seu cartão + +**Como funciona a exibição de custos:** + +O painel mostra **custos estimados** como se você estivesse usando APIs pagas diretamente. Isto **não é cobrança** - é uma ferramenta de comparação para mostrar suas economias. + +**Cenário de exemplo:** + +``` +Exibição no painel: +• Total de requisições: 1,662 +• Total de tokens: 47M +• Custo exibido: $290 + +Custo real: +• Provedor: Kiro (nível gratuito: ~50 créditos/mês) +• Pagamento real: $0.00 +• O que $290 significa: valor ECONOMIZADO ao usar modelos gratuitos! +``` + +**Regras de pagamento:** + +- **Provedores de assinatura** (Claude Code, Codex): pague diretamente por meio de seus sites +- **Provedores baratos** (GLM, MiniMax): pague diretamente, 9Router apenas roteia +- **Provedores GRATUITOS** (iFlow, Kiro, Qwen): Genuinamente grátis para sempre, sem taxas ocultas +- **9Router**: Nunca cobra nada + +--- + +## 🎯 Casos de uso + +### Caso 1: "Tenho assinatura do Claude Pro" + +**Problema:** A cota expira sem ser utilizada, limites de taxa durante codificação pesada + +**Solução:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (use toda a assinatura) + 2. glm/glm-5.1 (backup econômico quando a cota acabar) + 3. kr/claude-sonnet-4.5 (fallback gratuito de emergência) + +Custo mensal: $20 (assinatura) + ~$5 (backup) = $25 total +versus $20 + atingir limites = frustração +``` + +### Caso 2: "Quero custo zero" + +**Problema:** Não posso pagar assinaturas, preciso de codificação de IA confiável + +**Solução:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito via Kiro, ~50 créditos/mês) + 2. kr/glm-5 (GLM-5 gratuito via Kiro) + 3. oc/ (OpenCode Free, sem autenticação) + +Custo mensal: $0 +Qualidade: modelos prontos para produção + economia de 20–40% com RTK +``` + +### Caso 3: "Preciso de codificação 24 horas por dia, 7 dias por semana, sem interrupções" + +**Problema:** Prazos, não podemos arcar com o tempo de inatividade + +**Solução:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (melhor qualidade) + 2. cx/gpt-5.5 (segunda assinatura) + 3. glm/glm-5.1 (econômico, renova diariamente) + 4. minimax/MiniMax-M2.7 (mais econômico, renovação em 5h) + 5. kr/claude-sonnet-4.5 (gratuito via Kiro, ~50 créditos/mês) + +Resultado: 5 camadas de fallback = nenhuma interrupção +Custo mensal: $20-200 (subscriptions) + $10-20 (backup) +``` + +### Caso 4: "Quero IA GRATUITA no OpenClaw" + +**Problema:** Precisa de assistente de IA em aplicativos de mensagens (WhatsApp, Telegram, Slack...), totalmente gratuito + +**Solução:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito) + 2. kr/glm-5 (GLM-5 gratuito) + 3. kr/MiniMax-M2.5 (MiniMax gratuito) + +Custo mensal: $0 +Acesso por: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Perguntas frequentes + +
+📊 Por que meu painel mostra custos altos? + +O painel rastreia o uso do token e exibe **custos estimados** como se você estivesse usando APIs pagas diretamente. Este **não é faturamento real** - é uma referência para mostrar quanto você está economizando usando modelos gratuitos ou assinaturas existentes através do 9Router. + +**Exemplo:** + +- **O painel mostra:** "Custo total $290" +- **Realidade:** Você está usando modelos gratuitos do Kiro (~50 créditos/mês) +- **Seu custo real:** **$0.00** +- **O que significa $290:** Valor que você **economizou** usando modelos gratuitos em vez de APIs pagas! + +A exibição de custos é um "rastreador de economia" para ajudá-lo a entender seus padrões de uso e oportunidades de otimização. + +
+ +
+💳 Serei cobrado pelo 9Router? + +**Não.** 9Router é um software gratuito e de código aberto que roda em seu próprio computador. Nunca cobra nada. + +**Você só paga:** + +- ✅ **Provedores de assinatura** (Claude Code $20/mo, Codex $20-200/mo) → Pague-os diretamente em seus sites +- ✅ **Provedores baratos** (GLM, MiniMax) → Pague diretamente, 9Router apenas encaminha suas solicitações +- ❌ **9Router em si** → **Nunca carrega nada, nunca** + +9Router é um proxy/roteador local. Não possui cartão de crédito, não pode enviar faturas e não possui sistema de cobrança. É um software totalmente gratuito. + +
+ +
+🆓 Os provedores GRATUITOS são realmente ilimitados? + +**Principalmente!** Os provedores GRATUITOS atuais (Kiro, OpenCode Free, Vertex) são genuinamente gratuitos, mas os níveis gratuitos têm limites: + +Estes são serviços gratuitos oferecidos pelas respectivas empresas: + +- **Kiro AI**: ~50 créditos/mês grátis (mais 500 créditos de avaliação para novas contas nos primeiros 30 dias) via AWS Builder ID / Google / GitHub OAuth. Níveis pagos disponíveis acima disso. +- **OpenCode Free**: proxy de passagem sem autenticação, modelos buscados automaticamente em `opencode.ai/zen/v1/models`. A lista de modelos gratuitos varia com o tempo (alguns modelos são gratuitos apenas para promoções limitadas) — sujeita a alterações sem aviso prévio. +- **Vertex AI**: créditos gratuitos $300 para novas contas do Google Cloud (90 dias). Desde março de 2026, o endpoint da API Gemini não consome mais esses créditos. Em vez disso, use o endpoint **Vertex AI Studio**. + +O 9Router apenas encaminha suas solicitações para eles - não há pegadinhas ou cobrança futura do próprio 9Router. Eles são serviços verdadeiramente gratuitos e o 9Router os torna fáceis de usar com suporte alternativo. + +**Níveis gratuitos descontinuados (não mais recomendados):** + +- ❌ **iFlow**: era gratuito e ilimitado, agora alterado para pago (2026) +- ❌ **Qwen Code**: nível OAuth gratuito totalmente descontinuado pelo Alibaba em 15/04/2026 +- ❌ **Gemini CLI**: Serviço totalmente encerrado pelo Google em 18/06/2026 (substituído pelo Antigravity CLI de código fechado). Descontinuado – não use. + +
+ +
+💰 Como posso minimizar meus custos reais de IA? + +**Estratégia Grátis primeiro:** + +1. **Comece com um combo 100% grátis:** + + ``` + 1. kr/glm-5 (GLM-5 gratuito via Kiro, ~50 créditos/mês) + 2. Modelos do OpenCode Free (sem autenticação, obtidos automaticamente) + 3. Vertex AI Gemini 3 Pro (usando o endpoint do Vertex AI Studio com $300 em créditos) + ``` + + **Custo: $0/mês** (dentro do limite de crédito gratuito do Kiro; OpenCode/Vertex sujeito aos limites de nível gratuito) + +2. **Adicione backup barato** apenas se precisar: + + ``` + 4. glm/glm-4.7 ($0,60/1M tokens) + ``` + + **Custo adicional: pague apenas pelo que você realmente usa** + +3. **Use os provedores de assinatura por último:** + - Somente se você já os tiver +- 9Router ajuda a maximizar seu valor por meio do rastreamento de cotas + +**Resultado:** A maioria dos usuários pode operar em $0/mês usando apenas níveis gratuitos! + +
+ +
+📈 E se meu uso aumentar repentinamente? + +O fallback inteligente do 9Router evita cobranças surpresa: + +**Cenário:** você está em um sprint de codificação e ultrapassa suas cotas + +**Sem 9Router:** + +- ❌ Limite de requisições atingido → Paradas de trabalho → Frustração +- ❌ Ou: acidentalmente acumular enormes contas de API + +**Com 9Router:** + +- ✅ Assinatura atinge o limite → Retorno automático para nível barato +- ✅ O nível barato fica caro → Retorno automático para o nível gratuito +- ✅ Nunca pare de codificar → Custos previsíveis + +**Você está no controle:** Defina limites de gastos por provedor no painel e o 9Router os respeita. + +
+ +--- + +## 📖 Guia de configuração + +
+🔐 Provedores de assinatura (maximizar valor) + +### Claude Code (Pro/Max) + +```bash +Painel → Provedores → Conectar Claude Code +→ OAuth login → Renovação automática de token +→ Acompanhamento de cota de 5 horas + semanal + +Modelos: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Dica profissional:** Use o Opus para tarefas complexas e o Sonnet para velocidade. 9Router rastreia cota por modelo! + +### OpenAI Codex (Plus/Pro) + +```bash +Painel → Provedores → Conectar Codex +→ OAuth login (port 1455) +→ Renovação de 5 horas + semanal + +Modelos: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Painel → Provedores → Conectar GitHub +→ OAuth via GitHub +→ Renovação mensal (dia 1º) + +Modelos: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Painel → Provedores → Conectar Cursor +→ OAuth login +→ Assinatura mensal + +Modelos: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Provedores baratos (backup) + +### GLM-5.1 / GLM-4.7 (redefinição diária, $0.6/1M) + +1. Inscreva-se: [Zhipu AI](https://open.bigmodel.cn/) +2. Obtenha a chave API do plano de codificação +3. Painel → Adicionar chave API: + - Provedor: `glm` + - Chave API: `your-key` + +**Usar:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Dica profissional:** O plano de codificação oferece cota 3× com custo de 1/7! Redefinir diariamente às 10h. + +### MiniMax M2.7 (redefinição de 5h, $0.20/1M) + +1. Cadastre-se: [MiniMax](https://www.minimax.io/) +2. Obtenha a chave API +3. Painel → Adicionar chave API + +**Usar:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Dica profissional:** Opção mais barata para contexto longo (1 milhão de tokens)! + +### Kimi K2.5 ($9/mês plano) + +1. Inscreva-se: [Moonshot AI](https://platform.moonshot.ai/) +2. Obtenha a chave API +3. Painel → Adicionar chave API + +**Usar:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Dica profissional:** $9/mês corrigido para 10 milhões de tokens = custo efetivo de $0.90/1M! + +
+ +
+🆓 Provedores GRATUITOS (recomendado) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATUITO) + +```bash +Painel → Conectar Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google ou GitHub +→ Uso conforme a cota do plano + +Modelos: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Dica profissional:** Melhor opção gratuita para Claude. Sem chave API, sem pagamento, totalmente ilimitado. + +### OpenCode Free (sem autenticação, modelos de busca automática) + +```bash +Painel → Conectar OpenCode Free +→ Login não necessário (proxy de passagem) +→ Modelos obtidos automaticamente de opencode.ai/zen/v1/models +``` + +**Dica profissional:** Configuração mais rápida. Basta conectar e começar a codificar. + +### Vertex AI (créditos gratuitos $300 para novas contas do GCP) + +```bash +Painel → Conectar Vertex AI +→ Envie o JSON da conta de serviço do Google Cloud +→ Ative a API Vertex AI no projeto GCP + +Modelos: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Parceiros do Vertex (Anthropic / DeepSeek / GLM / Qwen via Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Dica profissional:** novas contas do Google Cloud recebem créditos $300 gratuitamente por 90 dias. Bastante para codificação diária. + +
+ +
+🎨 Criar Combos + +### Exemplo 1: Maximize a assinatura → Backup barato + +``` +Painel → Combos → Criar novo + +Nome: premium-coding +Modelos: + 1. cc/claude-opus-4-7 (assinatura principal) + 2. glm/glm-5.1 (backup econômico, $0,60/1M) + 3. minimax/MiniMax-M2.7 (fallback mais econômico, $0,20/1M) + +Use na CLI: premium-coding + +Exemplo de custo mensal (100M de tokens): + 80M via Claude (assinatura): $0 extra + 15M via GLM: $9 + 5M via MiniMax: $1 + Total: $10 + sua assinatura +``` + +### Exemplo 2: somente gratuito (custo zero) + +``` +Nome: free-combo +Modelos: + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito via Kiro, ~50 créditos/mês) + 2. kr/glm-5 (GLM-5 gratuito via Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 free credits) + +Custo: $0 (+ economia de 20–40% de tokens com RTK)! +``` + +
+ +
+🔧 Integração CLI + +### Cursor IDE + +``` +Configurações → Modelos → Avançado: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [copie do painel do 9Router] + Model: cc/claude-opus-4-7 +``` + +Ou use o combo: `premium-coding` + +### Claude Code + +Editar `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### CLI do Codex + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "seu prompt" +``` + +### OpenClaw + +**Opção 1 — Painel (recomendado):** + +``` +Painel → Ferramentas CLI → OpenClaw → Selecionar modelo → Aplicar +``` + +**Opção 2 — Manual:** Editar `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Nota:** OpenClaw só funciona com 9Router local. Use `127.0.0.1` em vez de `localhost` para evitar problemas de resolução IPv6. + +### Cline / Continue / RooCode + +``` +Provedor: compatível com OpenAI +Base URL: http://localhost:20128/v1 +API Key: [copie do painel] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Implantação + +### Implantação VPS + +```bash +# Clonar e instalar +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configurar +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Iniciar +npm run start + +# Ou usar PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Imagens publicadas (multiplataforma `linux/amd64` + `linux/arm64`): + +- Hub Docker: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Início rápido (use imagem publicada):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Abra http://localhost:20128 + +**Compilar a partir do código-fonte (desenvolvedor):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Padrões do contêiner:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Comandos úteis:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # atualizar para a versão mais recente +``` + +**Persistência de dados:** `$HOME/.9router/db/data.sqlite` no host ↔ `/app/data/db/data.sqlite` no contêiner. + +### Variáveis de Ambiente + +| Variável | Padrão | Descrição | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Gerado automaticamente (`~/.9router/jwt-secret`) | Segredo de assinatura JWT para cookie de autenticação do painel (substituir para compartilhar entre instâncias) | +| `INITIAL_PASSWORD` | `123456` | Primeira senha de login quando não existe hash salvo | +| `DATA_DIR` | `~/.9router` | Localização principal dos dados do aplicativo (SQLite em `$DATA_DIR/db/data.sqlite`) | +| `PORT` | padrão da estrutura | Porta de serviço (`20128` nos exemplos) | +| `HOSTNAME` | padrão da estrutura | Host de vinculação (o padrão do Docker é `0.0.0.0`) | +| `NODE_ENV` | padrão de tempo de execução | Definir `production` para implantação | +| `BASE_URL` | `http://localhost:20128` | URL base interna do lado do servidor usada por trabalhos de sincronização na nuvem | +| `CLOUD_URL` | `https://9router.com` | URL base do endpoint de sincronização na nuvem do lado do servidor | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL de base pública/compatível com versões anteriores (prefira `BASE_URL` para tempo de execução do servidor) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL de nuvem pública/compatível com versões anteriores (prefira `CLOUD_URL` para tempo de execução do servidor) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Segredo HMAC para chaves de API geradas | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt para hashing estável de ID de máquina | +| `ENABLE_REQUEST_LOGS` | `false` | Habilita logs de solicitação/resposta em `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Forçar cookie de autenticação `Secure` (definir `true` atrás do proxy reverso HTTPS) | +| `REQUIRE_API_KEY` | `false` | Aplicar chave de API Bearer em rotas `/v1/*` (recomendado para implantações expostas à Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vazio | Proxy de saída opcional para chamadas de provedor upstream | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint para o provedor de pesquisa na web SearXNG não autenticado integrado | + +Notas: + +- Variáveis de proxy em letras minúsculas também são suportadas: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` não está incluído na imagem Docker (`.dockerignore`); injete configuração de tempo de execução com `--env-file` ou `-e`. +- No Windows, `APPDATA` pode ser usado para resolução de caminho de armazenamento local. +- `INSTANCE_NAME` aparece em modelos de documentos/env mais antigos, mas atualmente não é usado em tempo de execução. + +### Arquivos e armazenamento em tempo de execução + +- Estado principal do aplicativo: `${DATA_DIR}/db/data.sqlite` (SQLite — provedores, combos, aliases, chaves, configurações, histórico de uso) +- Backups automáticos: `${DATA_DIR}/db/backups/` +- Logs opcionais de solicitação/tradutor: `/logs/...` quando `ENABLE_REQUEST_LOGS=true` +- `${DATA_DIR}` e `~/.9router` resolvem para o mesmo local em um contêiner Docker — o link simbólico `/root/.9router -> /app/data` é criado no momento da construção. + +
+ +--- + +## 📊 Modelos Disponíveis + +
+Ver todos os modelos disponíveis + +**Claude Code (`cc/`)** - Pro/Máx: + +-`cc/claude-opus-4-7` +-`cc/claude-opus-4-6` +-`cc/claude-sonnet-4-6` +-`cc/claude-sonnet-4-5-20250929` +-`cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +-`cx/gpt-5.5` +-`cx/gpt-5.4` +-`cx/gpt-5.3-codex` +-`cx/gpt-5.2-codex` +-`cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +-`gh/gpt-5.4` +-`gh/claude-opus-4.7` +-`gh/claude-sonnet-4.6` +-`gh/gemini-3.1-pro-preview` +-`gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Assinatura: + +-`cu/claude-4.6-opus-max` +-`cu/claude-4.5-sonnet-thinking` +-`cu/gpt-5.3-codex` +-`cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +-`glm/glm-5.1` +-`glm/glm-5` +-`glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +-`minimax/MiniMax-M2.7` +-`minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mo plana: + +-`kimi/kimi-k2.5` +-`kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - Gratuito (~50 créditos/mês, níveis pagos acima): + +-`kr/claude-sonnet-4.5` +-`kr/claude-haiku-4.5` +-`kr/glm-5` +-`kr/MiniMax-M2.5` +-`kr/qwen3-coder-next` +-`kr/deepseek-3.2` + +**OpenCode grátis (`oc/`)** - GRATUITO sem autenticação: + +- Obtido automaticamente de `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - Créditos gratuitos $300: + +-`vertex/gemini-3.1-pro-preview` +-`vertex/gemini-3-flash-preview` +-`vertex/gemini-2.5-flash` +-`vertex-partner/glm-5-maas` +-`vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Solução de problemas + +**"O modelo de linguagem não forneceu mensagens"** + +- Cota do provedor esgotada → Verifique o rastreador de cota do painel +- Solução: use o combo substituto ou mude para um nível mais barato + +**Limitação de requisições** + +- Cota de assinatura esgotada → Fallback para GLM/MiniMax +- Adicionar combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**O token OAuth expirou** + +- Atualizado automaticamente pelo 9Router +- Se os problemas persistirem: Painel → Provedor → Reconectar + +**Custos elevados** + +- Habilite RTK no Dashboard → Configurações de endpoint (padrão LIGADO, economiza 20-40% de tokens) +- Verifique as estatísticas de uso no Dashboard +- Mude o modelo primário para GLM/MiniMax +- Use o nível gratuito (Kiro, OpenCode Free, Vertex) para tarefas não críticas + +**Painel abre na porta errada** + +- Definir `PORT=20128` e `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**Primeiro login não funciona** + +- Verifique `INITIAL_PASSWORD` em `.env` +- Se não definida, a senha substituta é `123456` + +**Nenhum registro de solicitação em `logs/`** + +- Definir `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pilha de tecnologia + +- **Tempo de execução**: Node.js 20+ +- **Estrutura**: Next.js 16 +- **IU**: React 19 + Tailwind CSS 4 +- **Banco de dados**: SQLite (better-sqlite3 / node:sqlite / fallback para sql.js) +- **Streaming**: eventos enviados pelo servidor (SSE) +- **Autenticação**: OAuth 2.0 (PKCE) + JWT + chaves de API + +--- + +## 📝 Referência da API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Escreva uma função para..."} + ], + "stream": true +} +``` + +### Listar modelos + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Retorna todos os modelos e combos no formato OpenAI +``` + +## 📧 Suporte + +- **Site**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Colaboradores + +Obrigado a todos os colaboradores que ajudaram a tornar o 9Router melhor! + +[![Contribuidores](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Gráfico de estrelas + +[![Gráfico de estrelas](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Um fork TypeScript completo do 9Router. Adiciona mais de 36 provedores, fallback automático de 4 camadas, APIs multimodais (imagens, incorporações, áudio, TTS), disjuntor, cache semântico, avaliações LLM e um painel sofisticado. Mais de 368 testes de unidade. Disponível via npm e Docker. + +--- + +## 🙏 Agradecimentos + +Construído sobre ombros de gigantes: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implementação Go original que inspirou esta versão JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Economizador de tokens em Rust. O 9Router porta seu pipeline de compactação para JS → **−20-40% de tokens de entrada** em cada solicitação. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) por **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"por que usar muitos tokens quando poucos tokens resolvem"_. 9Router adapta seu prompt → **−65% tokens de saída**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Estrelas](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) por **[@DietrichGebert](https://github.com/DietrichGebert)** — _"desenvolvedor sênior preguiçoso"_ habilidade. 9Router injeta sua abordagem YAGNI-first → **menos tokens, menos código, diferenças mais curtas**. + +Um grande agradecimento a esses autores – sem o trabalho deles, os recursos de economia de tokens do 9Router não existiriam. Dê uma ⭐ a eles no GitHub! + +--- + +## 📄 Licença + +Licença MIT - consulte [LICENSE](../LICENSE) para obter detalhes. + +--- + +
+ Construído com ❤️ para desenvolvedores que codificam 24/7 +
+ diff --git a/i18n/README.th.md b/i18n/README.th.md new file mode 100644 index 00000000..cfd8dc4b --- /dev/null +++ b/i18n/README.th.md @@ -0,0 +1,723 @@ +นี่คือเอกสารแปลภาษาไทยของไฟล์ Markdown ต้นฉบับ โดยรักษาโครงสร้างและซินแท็กซ์ทางเทคนิคทั้งหมดไว้เหมือนเดิม + +
+ แดชบอร์ด 9Router + + # 9Router - Free AI Router + + **ไม่ต้องหยุดเขียนโค้ด ประหยัดโทเค็น 20-40% ด้วย RTK + สลับอัตโนมัติไปยังโมเดล AI ฟรีและราคาถูก** + + **ผู้ให้บริการ AI ฟรีสำหรับ OpenClaw** + +

+ OpenClaw +

+ + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 เริ่มต้นใช้งาน](#-quick-start) • [💡 ฟีเจอร์](#-key-features) • [📖 การตั้งค่า](#-setup-guide) • [🌐 เว็บไซต์](https://9router.com) +
+ +--- + +## 🤔 ทำไมต้อง 9Router? + +**หยุดเสียเงินและเจอขีดจำกัด:** + +- ❌ โควตาสมาชิกหมดอายุโดยไม่ได้ใช้ทุกเดือน +- ❌ Rate Limit หยุดคุณระหว่างเขียนโค้ด +- ❌ ค่า API แพง ($20-50/เดือน ต่อผู้ให้บริการแต่ละราย) +- ❌ ต้องสลับผู้ให้บริการด้วยตนเอง + +**9Router แก้ปัญหาเหล่านี้:** + +- ✅ **ประหยัดโทเค็น RTK** - บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`...) ก่อนส่งให้ LLM +- ✅ **เพิ่มประสิทธิภาพสมาชิก** - ติดตามโควตา ใช้ทุกบิตก่อนรีเซ็ต +- ✅ **สลับอัตโนมัติ** - สมาชิก → ถูก → ฟรี, ไม่มีเวลาหยุดทำงาน +- ✅ **รองรับหลายบัญชี** - Round-robin ระหว่างบัญชีของผู้ให้บริการแต่ละราย +- ✅ **ใช้งานได้ทุกที่** - ใช้ได้กับ Claude Code, Codex, Cursor, Cline, เครื่องมือ CLI ใดก็ได้ + +--- + +## 🔄 วิธีการทำงาน + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (ตัดโทเค็น tool_result) │ +│ • แปลงรูปแบบ (OpenAI ↔ Claude) │ +│ • ติดตามโควตา │ +│ • รีเฟรชโทเค็นอัตโนมัติ │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: สมาชิก] Claude Code, Codex, GitHub Copilot + │ ↓ โควตาหมด + ├─→ [Tier 2: ถูก] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ งบหมด + └─→ [Tier 3: ฟรี] Kiro, OpenCode Free, Vertex ($300 เครดิตฟรี) + +ผลลัพธ์: ไม่ต้องหยุดเขียนโค้ด ค่าใช้จ่ายน้อยที่สุด + ประหยัดโทเค็น 20-40% ด้วย RTK +``` + +--- + +## ⚡ เริ่มต้นใช้งาน + +**1. ติดตั้งแบบ Global:** + +```bash +npm install -g 9router +9router +``` + +🎉 เปิดแดชบอร์ดที่ `http://localhost:20128` + +**2. เชื่อมต่อผู้ให้บริการฟรี (ไม่ต้องสมัคร):** + +แดชบอร์ด → Providers → เชื่อมต่อ **Kiro AI** (Claude ฟรีไม่จำกัด) หรือ **OpenCode Free** (ไม่ต้องยืนยันตัวตน) → เสร็จ! + +**3. ใช้ในเครื่องมือ CLI ของคุณ:** + +``` +ตั้งค่า Claude Code/Codex/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [คัดลอกจากแดชบอร์ด] + Model: kr/claude-sonnet-4.5 +``` + +**เสร็จแล้ว!** เริ่มเขียนโค้ดด้วยโมเดล AI ฟรี + +**วิธีอื่น: รันจากซอร์สโค้ด (เก็บรักษาไว้ใน repo นี้):** + +Repo นี้เป็น private package (`9router-app`) ดังนั้นการรันจากซอร์ส/Docker คือเส้นทางพัฒนาท้องถิ่นที่คาดไว้ + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +โหมด Production: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL ค่าเริ่มต้น: +- แดชบอร์ด: `http://localhost:20128/dashboard` +- OpenAI-compatible API: `http://localhost:20128/v1` + +--- + +## 🛠️ เครื่องมือ CLI ที่รองรับ + +9Router ทำงานได้อย่างราบรื่นกับเครื่องมือเขียนโค้ด AI ทุกประเภท: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## ผู้ให้บริการที่รองรับ + +### 🔐 ผู้ให้บริการ OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 ผู้ให้บริการฟรี + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax • ไม่จำกัด ฟรี +
+ OpenCode
+ OpenCode Free
+ ไม่ต้องยืนยันตัวตน • ดึงโมเดลอัตโนมัติ • ไม่จำกัด ฟรี +
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek • เครดิตฟรี $300 +
+
+ +> **หมายเหตุ:** iFlow, Qwen และ Gemini CLI หยุดให้บริการในปี 2026 แล้ว ใช้ Kiro / OpenCode Free / Vertex แทน + +### 🔑 ผู้ให้บริการ API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...และผู้ให้บริการอีกกว่า 20 ราย รวมถึง Nebius, Chutes, Hyperbolic และ OpenAI/Anthropic compatible endpoints แบบกำหนดเอง

+
+ +--- + +## 💡 ฟีเจอร์หลัก + +| ฟีเจอร์ | ทำอะไร | ทำไมถึงสำคัญ | +|---------|--------------|----------------| +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`, `tree`...) ก่อนส่งให้ LLM | ประหยัด **โทเค็น input 20-40%** ต่อคำขอ | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | พร็อกซี `/v1/compress` ภายนอกก่อนเลือกผู้ให้บริการ | ประหยัดโทเค็นบริบทมากขึ้นโดยไม่ต้องเปลี่ยน client | +| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | ฉีด caveman-speak prompt → LLM ตอบสั้นกระชับ เนื้อหาทางเทคนิคยังครบถ้วน | ประหยัด **โทเค็น output สูงสุด 65%** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | ฉีด prompt "lazy senior dev" → LLM เขียนโค้ดน้อยที่สุด YAGNI-first (Lite/Full/Ultra) | **โทเค็น output น้อยลง, ไม่ต้อง refactor มาก** | +| 🎯 **Smart 3-Tier Fallback** | เลือกเส้นทางอัตโนมัติ: สมาชิก → ถูก → ฟรี | ไม่ต้องหยุดเขียนโค้ด, ไม่มีเวลาหยุดทำงาน | +| 📊 **ติดตามโควตาแบบ Real-Time** | นับโทเค็นแบบ live + นับถอยหลังรีเซ็ต | เพิ่มประสิทธิภาพมูลค่าสมาชิก | +| 🔄 **แปลงรูปแบบ** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | ใช้ได้กับเครื่องมือ CLI ทุกประเภท | +| 👥 **รองรับหลายบัญชี** | หลายบัญชีต่อผู้ให้บริการ | Load balancing + สำรองข้อมูล | +| 🔄 **รีเฟรชโทเค็นอัตโนมัติ** | OAuth token รีเฟรชอัตโนมัติ | ไม่ต้องล็อกอินซ้ำด้วยตนเอง | +| 🎨 **Combo กำหนดเอง** | สร้างการผสมผสานโมเดลไม่จำกัด | ปรับแต่ง fallback ตามความต้องการ | +| 📝 **บันทึก Request** | โหมด debug พร้อม log request/response ครบถ้วน | แก้ไขปัญหาได้ง่าย | +| 💾 **ซิงค์คลาวด์** | ซิงค์การตั้งค่าระหว่างอุปกรณ์ | การตั้งค่าเดียวกันทุกที่ | +| 📊 **วิเคราะห์การใช้งาน** | ติดตามโทเค็น, ค่าใช้จ่าย, แนวโน้มตามเวลา | ปรับแต่งค่าใช้จ่าย | +| 🌐 **Deploy ได้ทุกที่** | Localhost, VPS, Docker, Cloudflare Workers | ตัวเลือก deploy ที่ยืดหยุ่น | + +
+📖 รายละเอียดฟีเจอร์ + +### 🚀 RTK Token Saver + +ผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) มักกินงบประมาณ prompt 30-50% RTK ตรวจสอบและบีบอัดอย่างชาญฉลาดแบบ lossless **ก่อน**คำขอถึง LLM: + +- **ตัวกรอง:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **ตรวจจับอัตโนมัติ:** ไม่ต้องตั้งค่า — RTK .peek 1KB แรกของแต่ละ `tool_result` และเลือกตัวกรองที่ถูกต้อง +- **ปลอดภัยโดยการออกแบบ:** ถ้าตัวกรองล้มเหลว, ขว้าง error, หรือทำให้ผลลัพธ์ใหญ่ขึ้น RTK จะเก็บข้อความต้นฉบับไว้โดยเงียบๆ ไม่มี error ทำให้คำขอของคุณล้มเหลว +- **ใช้ได้ทุกที่:** ใช้ได้กับทุกรูปแบบ (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) เพราะทำงาน **ก่อน**การแปลงรูปแบบใดๆ +- **เปิดใช้งานเป็นค่าเริ่มต้น:** ปิด/เปิดได้ตลอดเวลาใน แดชบอร์ด → ตั้งค่า Endpoint + +``` +ไม่ใช้ RTK: ส่ง 47K โทเค็นให้ LLM +ใช้ RTK: ส่ง 28K โทเค็นให้ LLM (ประหยัด 40% · บริบทเดียวกัน · คำตอบเดียวกัน) +``` + +### 🧠 Headroom Token Saver + +Headroom เป็นตัวเลือกและทำงานแยกกัน 9Router เรียก endpoint `/v1/compress` ของ Headroom จากนั้นยังคงเลือกเส้นทาง, fallback, auth และติดตามการใช้งานตามปกติ: + +``` +Client → 9Router → Headroom /v1/compress → 9Router → provider +``` + +ตั้งค่าท้องถิ่น: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +เปิดใช้งานใน แดชบอร์ด → Endpoint → Token Saver → Headroom URL ค่าเริ่มต้น: `http://localhost:8787` + +ตัวอย่าง Docker: + +```bash +# Headroom service ใน Docker network เดียวกัน +http://host.docker.internal:8787 +``` + +ถ้า Headroom ดับหรือคืน error, 9Router จะ fail open และส่งคำขอต้นฉบับ + +### 🐴 Ponytail (Lazy Senior Dev) + +Ponytail ฉีด prompt *"lazy senior dev"* เข้าไปในทุกคำขอ ทำให้ LLM เขียนโค้ดน้อยที่สุดแบบ YAGNI-first — ลบมากกว่าเพิ่ม, stdlib มากกว่า dep ใหม่, one-liner มากกว่า abstraction + +- **Lite** — สร้างตามที่ขอ, บอกชื่อทางเลือกที่ lazy กว่า +- **Full** — บังคับ YAGNI ladder: stdlib → native → existing deps → one-liner → minimal code +- **Ultra** — YAGNI extremist: ลบก่อน, ส่ง one-liner, ตั้งคำถามกับ requirement ที่เหลือในคำตอบเดียวกัน + +``` +ไม่ใช้ Ponytail: โค้ดเยอะ, abstraction เยอะ, "เผื่อไว้" scaffolding +ใช้ Ponytail: diff สั้นที่สุดที่ทำงานได้, ไม่เพิ่ม abstraction ที่ไม่ได้ขอ, โทเค็นน้อยลง +``` + +ไม่มีวันแลก: input validation, error handling ที่ป้องกัน data loss, security, accessibility หรือสิ่งที่ขอมาอย่างชัดเจน เปิดใช้งานใน แดชบอร์ด → Endpoint → Ponytail ใช้คู่กับ Caveman (ความกระชับ output) และ RTK (การบีบอัด input) ได้ + +### 🎯 Smart 3-Tier Fallback + +สร้าง combo พร้อม fallback อัตโนมัติ: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (สมาชิกของคุณ) + 2. glm/glm-4.7 (สำรองราคาถูก, $0.6/1M) + 3. if/kimi-k2-thinking (fallback ฟรี) + +→ สลับอัตโนมัติเมื่อโควตาหมดหรือเกิด error +``` + +### 📊 ติดตามโควตาแบบ Real-Time + +- การใช้โทเค็นต่อผู้ให้บริการ +- นับถอยหลังรีเซ็ต (5 ชั่วโมง, รายวัน, รายสัปดาห์) +- ประมาณการค่าใช้จ่ายสำหรับชั้นแบบเสียค่าใช้จ่าย +- รายงานค่าใช้จ่ายรายเดือน + +### 🔄 แปลงรูปแบบ + +แปลงรูปแบบได้อย่างราบรื่น: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- เครื่องมือ CLI ของคุณส่งรูปแบบ OpenAI → 9Router แปลง → ผู้ให้บริการได้รับรูปแบบต้นฉบับ +- ใช้ได้กับเครื่องมือใดก็ได้ที่รองรับ custom OpenAI endpoints + +### 👥 รองรับหลายบัญชี + +- เพิ่มหลายบัญชีสำหรับผู้ให้บริการแต่ละราย +- เลือกเส้นทาง round-robin หรือตามลำดับความสำคัญอัตโนมัติ +- Fallback ไปยังบัญชีถัดไปเมื่อบัญชีหนึ่งชนโควตา + +### 🔄 รีเฟรชโทเค็นอัตโนมัติ + +- OAuth token รีเฟรชอัตโนมัติก่อนหมดอายุ +- ไม่ต้องยืนยันตัวตนใหม่ด้วยตนเอง +- ประสบการณ์ที่ราบรื่นบนผู้ให้บริการทุกราย + +### 🎨 Combo กำหนดเอง + +- สร้างการผสมผสานโมเดลไม่จำกัด +- ผสมชั้นสมาชิก, ราคาถูกและฟรี +- ตั้งชื่อ combo เพื่อเข้าถึงง่าย +- แชร์ combo ระหว่างอุปกรณ์ด้วยการซิงค์คลาวด์ + +### 📝 บันทึก Request + +- เปิดโหมด debug เพื่อดู log request/response ครบถ้วน +- ติดตาม API calls, headers และ payloads +- แก้ไขปัญหาการเชื่อมต่อ +- Export log เพื่อวิเคราะห์ + +### 💾 ซิงค์คลาวด์ + +- ซิงค์ผู้ให้บริการ, combo และการตั้งค่าระหว่างอุปกรณ์ +- ซิงค์เบื้องหลังอัตโนมัติ +- จัดเก็บข้อมูลแบบเข้ารหัสปลอดภัย +- เข้าถึงการตั้งค่าของคุณจากทุกที่ + +### 📊 วิเคราะห์การใช้งาน + +- ติดตามการใช้โทเค็นตามผู้ให้บริการและโมเดล +- ประมาณการค่าใช้จ่ายและแนวโน้มค่าใช้จ่าย +- รายงานและข้อมูลเชิงลึกรายเดือน +- ปรับแต่งค่าใช้จ่าย AI ของคุณ + +### 🌐 Deploy ได้ทุกที่ + +- 💻 **Localhost** - ค่าเริ่มต้น, ทำงานออฟไลน์ +- ☁️ **VPS/Cloud** - แชร์ระหว่างอุปกรณ์ +- 🐳 **Docker** - Deploy ด้วยคำสั่งเดียว +- 🚀 **Cloudflare Workers** - เครือข่าย edge ทั่วโลก + +
+ +--- + +## 💰 สรุปราคา + +| ประเภท | ผู้ให้บริการ | ค่าใช้จ่าย | รีเซ็ตโควตา | ดีที่สุดสำหรับ | +|------|----------|------|-------------|----------| +| **💳 สมาชิก** | Claude Code (Pro) | $20/เดือน | 5 ชม. + รายสัปดาห์ | มีสมาชิกอยู่แล้ว | +| | Codex (Plus/Pro) | $20-200/เดือน | 5 ชม. + รายสัปดาห์ | ผู้ใช้ OpenAI | +| | GitHub Copilot | $10-19/เดือน | รายเดือน | ผู้ใช้ GitHub | +| **💰 ราคาถูก** | GLM-4.7 | $0.6/1M | ทุกวัน 10:00 AM | สำรองงบ | +| | MiniMax M2.1 | $0.2/1M | 5 ชั่วโมง | ถูกที่สุด | +| | Kimi K2 | $9/เดือน คงที่ | 10M โทเค็น/เดือน | ค่าใช้จ่ายที่คาดเดาได้ | +| **🆓 ฟรี** | Kiro | $0 | ไม่จำกัด | Claude ฟรี | +| | OpenCode Free | $0 | ไม่จำกัด | ไม่ต้องยืนยันตัวตน | +| | Vertex AI | $0 | $300 เครดิตฟรี | Gemini 3 Pro | + +**💡 เคล็ดลับ:** เริ่มจาก combo Kiro (Claude ฟรีไม่จำกัด) + OpenCode Free (ไม่ต้องยืนยันตัวตน) = ค่าใช้จ่าย $0! + +--- + +## 🎯 กรณีการใช้งาน + +### กรณีที่ 1: "ฉันมีสมาชิก Claude Pro" + +**ปัญหา:** โควตาหมดอายุโดยไม่ได้ใช้, Rate Limit ตอนเขียนโค้ดหนัก + +**วิธีแก้:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (ใช้สมาชิกเต็มที่) + 2. glm/glm-4.7 (สำรองราคาถูกเมื่อโควตาหมด) + 3. kr/claude-sonnet-4.5 (fallback ฉุกเฉินฟรี) + +ค่าใช้จ่ายรายเดือน: $20 (สมาชิก) + ~$5 (สำรอง) = $25 รวม +เทียบกับ $20 + ชนโควตา = ผิดหวัง +``` + +### กรณีที่ 2: "ฉันต้องการค่าใช้จ่ายเป็นศูนย์" + +**ปัญหา:** ไม่มีงบจ่ายสมาชิก, ต้องการ AI เขียนโค้ดที่เชื่อถือได้ + +**วิธีแก้:** +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด) + 2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน) + 3. vertex/gemini-3.1-pro-preview (Vertex $300 เครดิตฟรี) + +ค่าใช้จ่ายรายเดือน: $0 +คุณภาพ: โมเดลพร้อมใช้งาน production +``` + +### กรณีที่ 3: "ฉันต้องเขียนโค้ด 24/7 ไม่มีสะดุด" + +**ปัญหา:** Deadline, ไม่สามารถหยุดทำงานได้ + +**วิธีแก้:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (คุณภาพดีที่สุด) + 2. cx/gpt-5.5 (สมาชิกที่สอง) + 3. glm/glm-5.1 (ราคาถูก, รีเซ็ตทุกวัน) + 4. minimax/MiniMax-M2.7 (ถูกที่สุด, รีเซ็ต 5 ชม.) + 5. kr/claude-sonnet-4.5 (ฟรีไม่จำกัด) + +ผลลัพธ์: 5 ชั้น fallback = ไม่มีเวลาหยุดทำงาน +ค่าใช้จ่ายเดือน: $20-200 (สมาชิก) + $10-20 (สำรอง) +``` + +### กรณีที่ 4: "ฉันต้องการ AI ฟรีใน OpenClaw" + +**ปัญหา:** ต้องการ AI assistant ในแอปพลิเคชันแชท (WhatsApp, Telegram, Slack...), ฟรีทั้งหมด + +**วิธีแก้:** +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด) + 2. kr/glm-5 (GLM ฟรีไม่จำกัด) + 3. kr/MiniMax-M2.5 (MiniMax ฟรีไม่จำกัด) + +ค่าใช้จ่ายรายเดือน: $0 +เข้าถึงผ่าน: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ คำถามที่พบบ่อย + +
+💳 9Router เก็บเงินฉันหรือไม่? + +**ไม่.** 9Router เป็นซอฟต์แวร์ฟรีแบบ open source ที่ทำงานบนเครื่องของคุณเอง มันไม่มีวันเรียกเก็บเงินจากคุณ + +**คุณจ่ายเงินเฉพาะ:** +- ✅ **ผู้ให้บริการสมาชิก** (Claude Code $20/เดือน, Codex $20-200/เดือน) → จ่ายตรงให้พวกเขาบนเว็บไซต์ของพวกเขา +- ✅ **ผู้ให้บริการราคาถูก** (GLM, MiniMax) → จ่ายตรงให้พวกเขา, 9Router แค่เลือกเส้นทางคำขอของคุณ +- ❌ **ตัว 9Router เอง** → **ไม่มีวันเรียกเก็บเงินใดๆ ทั้งสิ้น** + +9Router เป็น proxy/router ท้องถิ่น มันไม่มีบัตรเครดิตของคุณ, ไม่สามารถส่งใบแจ้งหนี้ได้ และไม่มีระบบชำระเงิน เป็นซอฟต์แวร์ฟรีทั้งหมด + +
+ +
+🆓 ผู้ให้บริการฟรีไม่จำกัดจริงหรือ? + +**จริง!** ผู้ให้บริการที่ระบุว่าฟรี (Kiro, OpenCode Free, Vertex) ไม่จำกัดจริงๆ **ไม่มีค่าใช้จ่ายแอบแฝง** + +นี่คือบริการฟรีที่บริษัทต่างๆ ให้บริการ: +- **Kiro**: Claude ฟรีไม่จำกัดผ่าน AWS Builder ID +- **OpenCode Free**: ไม่ต้องยืนยันตัวตน, ดึงโมเดลอัตโนมัติ +- **Vertex AI**: $300 เครดิตฟรีสำหรับ Gemini 3 Pro + +9Router แค่เลือกเส้นทางคำขอของคุณไปหาพวกเขา — ไม่มี "กับดัก" หรือการเรียกเก็บเงินในอนาคต เป็นบริการที่ฟรีจริงๆ และ 9Router ทำให้ใช้งานง่ายด้วยการรองรับ fallback + +
+ +
+💰 ทำอย่างไรเพื่อลดค่าใช้จ่าย AI จริงของฉัน? + +**กลยุทธ์ Free First:** + +1. **เริ่มจาก combo ฟรี 100%:** + ``` + 1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด) + 2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน) + 3. vertex/gemini-3.1-pro-preview ($300 เครดิตฟรี) + ``` + **ค่าใช้จ่าย: $0/เดือน** + +2. **เพิ่มสำรองราคาถูก** เมื่อจำเป็นเท่านั้น: + ``` + 4. glm/glm-5.1 ($0.6/1M โทเค็น) + ``` + **ค่าใช้จ่ายเพิ่มเติม:** จ่ายเฉพาะที่ใช้ + +3. **ใช้ผู้ให้บริการสมาชิก** ก็ต่อเมื่อมีอยู่แล้ว: + - 9Router ช่วยเพิ่มประสิทธิภาพมูลค่าของพวกเขาผ่านการติดตามโควตา + +**ผลลัพธ์:** ผู้ใช้ส่วนใหญ่สามารถทำงานที่ $0/เดือน โดยใช้เฉพาะชั้นฟรี! + +
+ +--- + +## 🐛 การแก้ไขปัญหา + +**"Language model did not provide messages"** +- โควตาผู้ให้บริการหมด → ตรวจสอบตัวติดตามโควตาในแดชบอร์ด +- วิธีแก้: ใช้ combo fallback หรือสลับไปชั้นที่ถูกกว่า + +**Rate Limiting** +- สมาชิกหมดโควตา → Fallback ไป GLM/MiniMax +- เพิ่ม combo: `cc/claude-opus-4-6 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth Token หมดอายุ** +- รีเฟรชอัตโนมัติโดย 9Router +- ถ้าปัญหายังคงอยู่: แดชบอร์ด → ผู้ให้บริการ → เชื่อมต่อใหม่ + +**ค่าใช้จ่ายสูง** +- เปิดใช้ RTK ใน แดชบอร์ด → ตั้งค่า Endpoint (เปิดเป็นค่าเริ่มต้น, ประหยัด 20-40% โทเค็น) +- ตรวจสอบสถิติการใช้งานในแดชบอร์ด +- สลับโมเดลหลักไป GLM/MiniMax +- ใช้ชั้นฟรี (Kiro, OpenCode Free, Vertex) สำหรับงานที่ไม่สำคัญ + +**แดชบอร์ดเปิดผิดพอร์ต** +- ตั้ง `PORT=20128` และ `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**ล็อกอินครั้งแรกไม่ทำงาน** +- ตรวจสอบ `INITIAL_PASSWORD` ใน `.env` +- ถ้ายังไม่ตั้งค่า รหัสผ่านสำรองคือ `123456` + +**ไม่มี request log ใต้ `logs/`** +- ตั้ง `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API Reference + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "เขียนฟังก์ชันเพื่อ..."} + ], + "stream": true +} +``` + +### List Models + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ คืนค่าโมเดลทั้งหมด + combo ในรูปแบบ OpenAI +``` + +--- + +## 📧 สนับสนุน + +- **เว็บไซต์**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 ผู้มีส่วนร่วม + +ขอขอบคุณผู้มีส่วนร่วมทุกคนที่ช่วยทำให้ 9Router ดียิ่งขึ้น! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📄 ลิขสิทธิ์ + +MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด + +--- + +
+ สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7 +
diff --git a/i18n/README.vi.md b/i18n/README.vi.md index d0a51de4..5e01d8aa 100644 --- a/i18n/README.vi.md +++ b/i18n/README.vi.md @@ -1,21 +1,15 @@ -Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ nguyên toàn bộ cú pháp và cấu trúc kỹ thuật. -
Bảng điều khiển 9Router - # 9Router - Free AI Router + # 9Router - Free AI Router & Token Saver - **Không bao giờ ngừng code. Tự động định tuyến tới các mô hình AI MIỄN PHÍ & giá rẻ với cơ chế dự phòng thông minh.** + **Không bao giờ ngừng code. Tiết kiệm 20-40% token với RTK + tự động dự phòng sang các mô hình AI MIỄN PHÍ & giá rẻ.** - **Nhà cung cấp AI Miễn cho OpenClaw.** - -

- OpenClaw -

+ **Kết nối tất cả công cụ AI Code (Claude Code, Codex, Cursor, Cline, Copilot, Antigravity...) tới 40+ Nhà cung cấp AI & 100+ Mô hình.** [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) - [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + [![License](https://github.com/decolua/9router/blob/main/LICENSE)](https://github.com/decolua/9router/blob/main/LICENSE) [🚀 Bắt đầu nhanh](#-quick-start) • [💡 Tính năng](#-key-features) • [📖 Cài đặt](#-setup-guide) • [🌐 Website](https://9router.com)
@@ -24,19 +18,21 @@ Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ ## 🤔 Tại sao chọn 9Router? -**Ngừng lãng phí tiền bạc và gặp phải giới hạn:** +**Ngừng lãng phí tiền bạc, token và không bao giờ lo chạm giới hạn (rate limit):** - ❌ Hạn mức gói đăng ký hết hạn mỗi tháng mà không dùng hết -- ❌ Giới hạn tốc độ (rate limit) ngăn bạn giữaừng khi code -- ❌ Các API đắt đỏ ($20-50/tháng cho mỗi nhà cung cấp) -- ❌ Phải chuyển đổi thủ công giữa các nhà cung cấp +- ❌ Giới hạn tốc độ (rate limit) làm gián đoạn công việc mid-coding +- ❌ Kết quả của công cụ (git diff, grep, ls...) ngốn rất nhiều token +- ❌ Chi phí API đắt đỏ ($20-50/tháng cho từng nhà cung cấp) +- ❌ Phải chuyển đổi thủ công giữa các nhà cung cấp AI **9Router giải quyết vấn đề này:** -- ✅ **Tối đa hóa gói đăng ký** - Theo dõi hạn mức, sử dụng từng bit trước khi reset -- ✅ **Tự động dự phòng** - Gói đăng ký → Giá rẻ → Miễn phí, thời gian chết bằng không -- ✅ **Đa tài khoản** - Vòng tròn (round-robin) các tài khoản của mỗi nhà cung cấp -- ✅ **Phổ quát** - Hoạt động với Claude Code, Codex, Gemini CLI, Cursor, Cline, bất kỳ công cụ CLI nào +- ✅ **RTK Token Saver** - Tự động nén nội dung `tool_result`, tiết kiệm 20-40% token trên mỗi request +- ✅ **Tối đa hóa gói đăng ký** - Theo dõi hạn mức, tận dụng triệt để trước khi reset +- ✅ **Tự động dự phòng (Auto Fallback)** - Gói đăng ký → Giá rẻ → Miễn phí, không lo downtime +- ✅ **Đa tài khoản (Multi-account)** - Xoay vòng (round-robin) các tài khoản cho mỗi nhà cung cấp +- ✅ **Phổ quát (Universal)** - Hoạt động với Claude Code, Codex, Cursor, Cline, Antigravity và mọi công cụ CLI --- @@ -44,25 +40,26 @@ Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ ``` ┌─────────────┐ -│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) -│ Tool │ +│ Công cụ │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...) +│ CLI AI │ └──────┬──────┘ │ http://localhost:20128/v1 ↓ -┌────────────────────────────────────────┐ -│ 9Router (Smart Router) │ -│ • Format translation (OpenAI ↔ Claude) │ -│ • Quota tracking │ -│ • Auto token refresh │ -└──────┬──────────────────────────────────┘ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (nén tool_result token) │ +│ • Dịch chuyển định dạng (OpenAI ↔ Claude) │ +│ • Quota tracking (theo dõi hạn mức) │ +│ • Tự động làm mới OAuth Token │ +└──────┬──────────────────────────────────────┘ │ - ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI - │ ↓ quota exhausted - ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) - │ budget limit - └─→ [Tier 3: FREE] iFlow, Qwen, Kiro (unlimited) + ├─→ [Tier 1: GÓI ĐĂNG KÝ] Claude Code, Codex, GitHub Copilot + │ ↓ hết hạn mức quota + ├─→ [Tier 2: GIÁ RẺ] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ chạm ngân sách + └─→ [Tier 3: MIỄN PHÍ] Kiro AI, OpenCode Free, Vertex AI ($300 credits) -Result: Never stop coding, minimal cost +Kết quả: Không bao giờ ngừng code, chi phí tối thiểu + tiết kiệm 20-40% token qua RTK ``` --- @@ -76,26 +73,26 @@ npm install -g 9router 9router ``` -🎉 Bảng điều khiển mở tại `http://localhost:20128` +🎉 Bảng điều khiển (Dashboard) sẽ tự động mở tại `http://localhost:20128` **2. Kết nối nhà cung cấp MIỄN PHÍ (không cần đăng ký):** -Bảng điều khiển → Providers -> Kết nối **ude Code** hoặc **Antigravity** -> Đăng nhập OAuth -> Xong! +Bảng điều khiển → Providers → Kết nối **Kiro AI** (~50 credits/tháng miễn phí: Claude 4.5 + GLM-5 + MiniMax) hoặc **OpenCode Free** (không cần auth) → Xong! **3. Sử dụng trong công cụ CLI của bạn:** ``` -Cài đặt Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: +Cài đặt Claude Code/Codex/OpenClaw/Cursor/Cline/Antigravity: Endpoint: http://localhost:20128/v1 API Key: [sao chép từ bảng điều khiển] - Model: if/kimi-k2-thinking + Model: kr/claude-sonnet-4.5 ``` -**Xong rồi!** Bắt đầu code với các mô hình AI MIỄN PHÍ. +**Thế là xong!** Bắt đầu code ngay với các mô hình AI MIỄN PHÍ. -**Phương án khác: chạy từ nguồn (k lưu trữ này):** +**Phương án khác: chạy từ mã nguồn (repository này):** -Gói kho lưu trữ này là riêng tư (`9router-app`), vì vậy việc thực thi nguồn/Docker là đường dẫn phát triển cục bộ dự kiến. +Gói kho lưu trữ này là riêng tư (`9router-app`), vì vậy việc chạy từ nguồn/Docker là cách phát triển cục bộ mặc định. ```bash cp .env.example .env @@ -111,11 +108,12 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run ``` URL mặc định: -- Bảng điều khiển: `http://localhost:20128/dashboard` +- Bảng điều khiển Dashboard: `http://localhost:20128/dashboard` - API tương thích OpenAI: `http://localhost:20128/v1` --- + ## 🎥 Hướng dẫn Video
diff --git a/i18n/README.zh-CN.md b/i18n/README.zh-CN.md index 92e0624b..c090f608 100644 --- a/i18n/README.zh-CN.md +++ b/i18n/README.zh-CN.md @@ -42,25 +42,26 @@ ``` ┌─────────────┐ -│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...) │ Tool │ └──────┬──────┘ - │ http://localhost:201281 + │ http://localhost:20128/v1 ↓ -┌─────────────────────────────────────────┐ -│ 9Router (Smart Router) │ -│ • Format translation (OpenAI ↔ Claude) │ -│ • Quota tracking │ -│ • Auto token refresh │ -└──────┬──────────────────────────────────┘ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (节省 20-40% Token) │ +│ • 格式转换 (OpenAI ↔ Claude) │ +│ • 配额追踪 (Quota tracking) │ +│ • 自动刷新 OAuth Token │ +└──────┬──────────────────────────────────────┘ │ - ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI - │ ↓ quota exhausted - ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) - │ ↓ budget limit - └─→ [Tier 3: FREE] iFlow, Qwen, Kiro (unlimited) + ├─→ [Tier 1: 订阅] Claude Code, Codex, GitHub Copilot + │ ↓ 配额用尽 + ├─→ [Tier 2: 低价] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ 触及预算上限 + └─→ [Tier 3: 免费] Kiro AI, OpenCode Free, Vertex AI ($300 credits) -Result: Never stop coding, minimal cost +结果:永不停歇的编程体验,最低成本 + 通过 RTK 节省 20-40% Token ``` --- diff --git a/next.config.mjs b/next.config.mjs index 91f55af9..ecd385ca 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -13,7 +13,14 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE const nextConfig = { distDir: process.env.NEXT_DIST_DIR || ".next", output: "standalone", - serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"], + // `open` must stay external. It derives its own directory from `import.meta.url`, and + // webpack replaces that with the absolute path of the BUILD machine as a string literal. + // A release built on macOS therefore ships `file:///Users/.../open/index.js`, which + // `fileURLToPath` rejects on Windows ("File URL path must be absolute" — no drive + // letter). That throw happens at module scope, so every consumer of `open` dies on + // import — including xAI/Grok token refresh, which loads the OAuth service that imports + // it. Keeping it external preserves the real `import.meta.url` at runtime. + serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "open"], turbopack: { root: tracingRoot }, @@ -30,6 +37,8 @@ const nextConfig = { proxyClientMaxBodySize, // Cache fetch responses across HMR refreshes for faster dev reloads. serverComponentsHmrCache: true, + // Tree-shake heavy barrel imports to cut compile + bundle size + optimizePackageImports: ["@xyflow/react", "@dnd-kit/core", "@dnd-kit/sortable", "material-symbols", "marked"], }, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 6ac9d324..5ddf95ac 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -1,5 +1,7 @@ -import { platform, arch } from "os"; +import { platform, arch, hostname } from "os"; import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js"; +import { ANTIGRAVITY_IDE_USER_AGENT } from "../providers/shared.js"; +import { createRequire } from "module"; // === Gemini CLI === derive từ registry gemini-cli.transport export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion; @@ -59,7 +61,7 @@ export function getPlatformEnum() { } export function getPlatformUserAgent() { - return `antigravity/1.104.0 ${platform()}/${arch()}`; + return ANTIGRAVITY_IDE_USER_AGENT; } export const CLIENT_METADATA = { @@ -129,13 +131,22 @@ export const AG_DEFAULT_TOOLS = new Set([ // Antigravity chat/stream headers export const ANTIGRAVITY_HEADERS = { - "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}` + "User-Agent": ANTIGRAVITY_IDE_USER_AGENT }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + // Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host + // rejects these auth/onboarding calls. Only chat traffic uses the daily host + // (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429). + antigravity: { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; export const LOAD_CODE_ASSIST_HEADERS = { @@ -145,6 +156,13 @@ export const LOAD_CODE_ASSIST_HEADERS = { "Client-Metadata": JSON.stringify({ ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }), }; +// Real Antigravity IDE doesn't send X-Goog-Api-Client/Client-Metadata on loadCodeAssist/onboardUser — +// Google's backend fingerprints those and silently refuses to provision a cloudaicompanionProject. +export const ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS = { + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_IDE_USER_AGENT, +}; + export const LOAD_CODE_ASSIST_METADATA = { ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), @@ -165,17 +183,48 @@ export const OAUTH_ENDPOINTS = { google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" }, openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl }, anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep - qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl }, iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl }, github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl }, }; -// Generate Kimi OAuth custom headers -export function buildKimiHeaders() { +let _appVersion; +function getAppPackageVersion() { + if (_appVersion) return _appVersion; + try { + const require = createRequire(import.meta.url); + _appVersion = require("../../package.json").version || "0.0.0"; + } catch { + _appVersion = process.env.npm_package_version || "0.0.0"; + } + return _appVersion; +} + +// Kimi Code OAuth / API headers (CLIProxyAPI internal/auth/kimi commonHeaders parity). +// deviceId must stay stable per connection for the whole OAuth session. +export function buildKimiHeaders(deviceId) { + const osName = platform(); + const architecture = arch(); + let deviceModel = `${osName} ${architecture}`; + if (osName === "darwin") deviceModel = `macOS ${architecture}`; + else if (osName === "win32") deviceModel = `Windows ${architecture}`; + else if (osName === "linux") deviceModel = `Linux ${architecture}`; + + let deviceName = "unknown"; + try { + deviceName = hostname() || "unknown"; + } catch { + deviceName = "unknown"; + } + + const resolvedId = (typeof deviceId === "string" && deviceId.trim()) + ? deviceId.trim() + : `kimi-${Date.now()}`; + return { "X-Msh-Platform": "9router", - "X-Msh-Version": "2.1.2", - "X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown", - "X-Msh-Device-Id": `kimi-${Date.now()}` + "X-Msh-Version": getAppPackageVersion(), + "X-Msh-Device-Name": deviceName, + "X-Msh-Device-Model": deviceModel, + "X-Msh-Device-Id": resolvedId, }; } diff --git a/open-sse/config/grokCli.js b/open-sse/config/grokCli.js new file mode 100644 index 00000000..f2e024e4 --- /dev/null +++ b/open-sse/config/grokCli.js @@ -0,0 +1,10 @@ +export const GROK_CLI_VERSION = "0.2.99"; +export const GROK_CLI_MODEL = "grok-build"; +export const GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +export const GROK_CLI_CLIENT_IDENTIFIER = "grok-shell"; +export const GROK_CLI_USER_AGENT = `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`; + +export function supportsGrokCliReasoningEffort(model) { + // ponytail: unknown models omit effort until live metadata reaches dispatch. + return /^grok-4\.5(?:$|-)/.test(String(model || "")); +} diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 3ff6acbb..e6408da1 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -8,18 +8,24 @@ * - `-agentic` model suffix detection + chunked-write system prompt * - reasoning / thinking trigger detection (Anthropic-Beta header, * Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag) - * - the `enabled` system-prompt injection - * that turns Kiro reasoning on + * - schema-specific native effort fields for supported GPT and Claude models + * - legacy `` system-prompt injection for other models * * Kiro upstream does not advertise `-agentic` model IDs; they are a 9router * fiction. The suffix is stripped before the request leaves this process. */ -import { extractThinking } from "../translator/concerns/thinkingUnified.js"; +import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js"; import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; export const KIRO_THINKING_SUFFIX = "-thinking"; +export const KIRO_TOOL_NAME_MAX_LENGTH = 64; +export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237; +export const KIRO_TOOL_ID_MAX_LENGTH = 64; +export const KIRO_CODEWHISPERER_TARGET = + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; +export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]); // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. // Used when an account cannot resolve its own profileArn. Builder ID and social @@ -40,6 +46,39 @@ export function resolveDefaultProfileArn(authMethod) { export const KIRO_THINKING_BUDGET_DEFAULT = 16000; +/** + * Resolve a Kiro model after consuming the generic model(level) suffix. + * The suffix is a 9router request override, not part of Kiro's upstream model id. + */ +export function resolveKiroModelIntent(model) { + const { cleanModel, override } = parseSuffix(model); + return { + model: cleanModel, + ...resolveKiroModel(cleanModel), + thinkingOverride: override, + }; +} + +/** Apply a parsed model(level) override without mutating the caller's body. */ +export function applyKiroThinkingOverride(body, override) { + if (!override) return body; + + const next = { ...body }; + if (override.mode === "budget") { + delete next.output_config; + delete next.reasoning_effort; + delete next.reasoning; + next.thinking = { type: "enabled", budget_tokens: override.budget }; + return next; + } + + next.output_config = { + ...(body.output_config || {}), + effort: override.mode === "level" ? override.level : override.mode, + }; + return next; +} + export const KIRO_AGENTIC_SYSTEM_PROMPT = ` # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) @@ -109,6 +148,7 @@ export function resolveKiroThinkingBudget(body, headers, model) { const cfg = extractThinking(body); if (cfg) { if (cfg.mode === "none") return null; + if (cfg.mode === "level" && cfg.level === "disabled") return null; if (cfg.mode === "budget") return cfg.budget; if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT; return KIRO_THINKING_BUDGET_DEFAULT; @@ -131,6 +171,86 @@ export function resolveKiroThinkingBudget(body, headers, model) { return null; } +export function extractKiroEffortLevel(body) { + const effort = + body?.output_config?.effort ?? + body?.reasoning_effort ?? + (typeof body?.reasoning === "object" ? body.reasoning?.effort : null); + if (typeof effort !== "string") return null; + const normalized = effort.toLowerCase(); + if (normalized === "none" || normalized === "off" || normalized === "disabled") return null; + if (normalized === "xhigh" || normalized === "max") return "high"; + if (["low", "medium", "high"].includes(normalized)) return normalized; + return null; +} + +function extractKiroGptEffortLevel(body) { + const effort = + body?.output_config?.effort ?? + body?.reasoning_effort ?? + (typeof body?.reasoning === "object" ? body.reasoning?.effort : null); + if (typeof effort !== "string") return null; + const normalized = effort.toLowerCase(); + if (normalized === "max") return "xhigh"; + // Kiro CLI does not advertise an explicit GPT "none" wire value; omit it. + if (["low", "medium", "high", "xhigh"].includes(normalized)) { + return normalized; + } + return null; +} + +export function buildKiroAdditionalModelRequestFields(body, effortPath = "output_config") { + const effort = effortPath === "reasoning" + ? extractKiroGptEffortLevel(body) + : extractKiroEffortLevel(body); + if (!effort) return undefined; + if (effortPath === "reasoning") { + // Mirrors Kiro CLI/KAS buildEffortRequestFields("reasoning") for GPT. + return { reasoning: { effort } }; + } + // Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config"). + return { + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort }, + }; +} + +export function resolveKiroEffortPath(model) { + if (typeof model !== "string") return null; + const normalized = model.toLowerCase().replace(/-/g, "."); + if (/(?:^|[/.])gpt[/.]5[/.]6(?:[/.]|$)/.test(normalized)) { + return "reasoning"; + } + if (!normalized.includes("claude")) return null; + const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/); + if (!match) return null; + const [, majorText, minorText] = match; + const major = Number(majorText); + const minor = minorText === undefined ? null : Number(minorText); + const dateSuffixMinor = minor !== null && minor >= 1000; + // Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke. + // Default future Claude/Kiro models to supported so new model releases do not + // need a code allowlist update. + return major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)) + ? null + : "output_config"; +} + +export function supportsKiroAdditionalModelRequestFields(model) { + return resolveKiroEffortPath(model) !== null; +} + +export function usesKiroNativeGptEffort(body, model) { + return resolveKiroEffortPath(model) === "reasoning" + && extractKiroGptEffortLevel(body) !== null; +} + +export function buildKiroAdditionalModelRequestFieldsForModel(body, model) { + const effortPath = resolveKiroEffortPath(model); + if (!effortPath) return undefined; + return buildKiroAdditionalModelRequestFields(body, effortPath); +} + /** * Detect whether an inbound request is asking for reasoning / thinking output. * Thin wrapper over resolveKiroThinkingBudget (single source of truth). diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index c4cfa413..300fef5a 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -2,9 +2,8 @@ import { PROVIDERS } from "./providers.js"; import REGISTRY from "../providers/registry/index.js"; // PROVIDER_MODELS now built from providers/registry (transport + models co-located) import { PROVIDER_MODELS } from "../providers/index.js"; -import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js"; +import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; - export { PROVIDER_MODELS }; @@ -18,46 +17,82 @@ export function getDefaultModel(aliasOrId) { return models?.[0]?.id || null; } +// Providers whose registry uses dots in version numbers (e.g. "claude-sonnet-4.5"). +// For these, we tolerate clients sending dashes ("claude-sonnet-4-5") by normalizing +// digit-hyphen-digit to digit-dot-digit before lookup. Other providers are left untouched. +const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]); + +// Find a registry entry by id. For Kiro models, tolerates dash/dot version separators +// ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only. +function findModel(models, modelId, aliasOrId) { + if (!models) return undefined; + const found = models.find(m => m.id === modelId); + if (found) return found; + if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined; + const normalized = normalizeModelId(modelId); + if (normalized === modelId) return undefined; + return models.find(m => m.id === normalized); +} + export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) { if (passthroughProviders.has(aliasOrId)) return true; const models = PROVIDER_MODELS[aliasOrId]; if (!models) return false; - return models.some(m => m.id === modelId); + return !!findModel(models, modelId, aliasOrId); } export function findModelName(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return modelId; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.name || modelId; } export function getModelTargetFormat(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - return modelTargetFormat(models.find(m => m.id === modelId)); + return modelTargetFormat(findModel(models, modelId, aliasOrId)); +} + +// Declared upstream formats for a model (registry `supportedFormats`). Drives the +// per-model guard on the sourceFormat-matched transport; null when undeclared. +export function getModelSupportedFormats(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return null; + return modelSupportedFormats(findModel(models, modelId, aliasOrId)); } export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.kind || found?.type || null; } export function getModelUpstreamId(aliasOrId, modelId) { + // Split off thinking suffix "(level)" so lookup hits the base id; re-append it to + // the result so downstream applyThinking still sees the suffix (body.model is stripped separately). + const sufMatch = typeof modelId === "string" ? modelId.match(/\([^()]+\)\s*$/) : null; + const suffix = sufMatch ? sufMatch[0] : ""; + const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; - const found = models?.find(m => m.id === modelId); - if (found?.upstreamModelId) return found.upstreamModelId; - if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { - return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); + const found = findModel(models, baseId, aliasOrId); + const resolvedId = found?.upstreamModelId || found?.id; + if (resolvedId) { + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId; + return resolvedBase + (suffix || presetSuffix); } - return modelId; + if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { + return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; + } + return baseId + suffix; } export function getModelQuotaFamily(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - return modelQuotaFamily(models?.find(m => m.id === modelId)); + return modelQuotaFamily(findModel(models, modelId, aliasOrId)); } // OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id. @@ -79,5 +114,5 @@ export function getModelsByProviderId(providerId) { // Get strip list for a model entry (explicit opt-in only) // Returns array of content types to strip, e.g. ["image", "audio"] export function getModelStrip(alias, modelId) { - return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId)); + return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias)); } diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index de199233..a27d3ea9 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -39,6 +39,15 @@ function envMs(name, def) { return Number.isFinite(n) && n > 0 ? n : def; } +function envUrl(name, def) { + const raw = process.env[name]?.trim(); + return raw || def; +} + +// SearXNG endpoint used by the unauthenticated web-search provider. +// Configure this for a separate Docker service or remote SearXNG instance. +export const SEARXNG_URL = envUrl("SEARXNG_URL", "http://localhost:8888/search"); + // Inter-chunk stall timeout (once tokens are flowing). Generous headroom so // slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS. export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000); @@ -56,6 +65,8 @@ export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MIN_TOKENS = 32000; +export const TOKEN_SAVER_HEADER = "x-9router-token-saver"; + // Retry config for 429 responses (legacy - kept for backward compatibility) export const RETRY_CONFIG = { maxAttempts: 2, diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.js index 6925f5f5..0142f6f1 100644 --- a/open-sse/config/ttsModels.js +++ b/open-sse/config/ttsModels.js @@ -33,6 +33,21 @@ const GEMINI_VOICES = [ "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat", ].map((id) => ({ id, name: id, type: "tts" })); +// Xiaomi MiMo preset voices (from https://mimo.mi.com/docs/zh-CN/quick-start/usage-guide/audio/speech-synthesis-v2.5). +// Voice id is passed via `audio.voice`; `mimo_default` = default (冰糖 on CN cluster, Mia elsewhere). +// Voices are language-independent — the spoken language is a separate hint, not bound to the voice. +const MIMO_VOICES = [ + { id: "mimo_default", name: "mimo_default" }, + { id: "冰糖", name: "冰糖" }, + { id: "茉莉", name: "茉莉" }, + { id: "苏打", name: "苏打" }, + { id: "白桦", name: "白桦" }, + { id: "Mia", name: "Mia" }, + { id: "Chloe", name: "Chloe" }, + { id: "Milo", name: "Milo" }, + { id: "Dean", name: "Dean" }, +].map((v) => ({ type: "tts", ...v })); + // ── TTS Config (config-driven, single source of truth) ───────────────────── export const TTS_MODELS_CONFIG = { openai: { @@ -107,6 +122,14 @@ export const TTS_MODELS_CONFIG = { }, allVoices: GEMINI_VOICES, }, + "xiaomi-mimo": { + models: [ + { id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS", type: "tts" }, + ], + voices: { + "mimo-v2.5-tts": MIMO_VOICES, + }, + }, }; // ── Helper: get voices for a specific model ──────────────────────────────── diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 5249a3ec..07bbb4fc 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -1,7 +1,7 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; +import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; @@ -18,7 +18,8 @@ function sanitizeFunctionName(name) { const MAX_RETRY_AFTER_MS = 10000; const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000; -const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; +const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 64000; +const ANTIGRAVITY_IDE_REQUEST_ID_RE = /^agent\/[^/]+\/\d+\/[^/]+\/\d+$/; const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [ /high\s+traffic/i, @@ -87,6 +88,27 @@ function parseImageConfig(model) { return config; } +function uuidFromSeed(seed) { + const bytes = crypto.createHash("sha256").update(String(seed || "antigravity")).digest().subarray(0, 16); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function buildIdeRequestId({ body, request, credentials, model, requestType }) { + if (ANTIGRAVITY_IDE_REQUEST_ID_RE.test(body?.requestId || "")) { + return body.requestId; + } + + const sessionId = request?.sessionId || body?.request?.sessionId || credentials?._clientSessionId || credentials?.connectionId || credentials?.email || "anonymous"; + const conversationId = uuidFromSeed(`antigravity:conversation:${sessionId}`); + const trajectoryId = uuidFromSeed(`antigravity:trajectory:${sessionId}:${model}:${requestType}`); + const contentCount = Array.isArray(request?.contents) ? request.contents.length : 1; + const step = Math.max(1, contentCount * 2 - 1); + return `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`; +} + export class AntigravityExecutor extends BaseExecutor { constructor() { super("antigravity", PROVIDERS.antigravity); @@ -104,20 +126,20 @@ export class AntigravityExecutor extends BaseExecutor { // sessionId comes from transformRequest output; base.execute runs transformRequest before // buildHeaders, so we read it from instance state cached there (fallback: explicit arg). buildHeaders(credentials, stream = true, sessionId = null) { - const sid = sessionId || this._lastSessionId; return { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.accessToken}`, "User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"], - [INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value, - ...(sid && { "X-Machine-Session-Id": sid }), - "Accept": stream ? "text/event-stream" : "application/json" }; } transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); + // OpenAI clients may include stream_options even for non-streaming calls. + // Google generateContent rejects that combination before processing the request. + if (stream !== true) delete body.stream_options; + // ─── Image generation: completely different request structure ─── if (isImageModel(model)) { const imageConfig = parseImageConfig(model); @@ -142,25 +164,26 @@ export class AntigravityExecutor extends BaseExecutor { }); this._lastSessionId = sessionId; + const request = { + contents, + generationConfig: { + temperature: 1.0, + topP: 0.95, + topK: 40, + maxOutputTokens: 8192, + imageConfig, + }, + sessionId, + // No tools, no systemInstruction, no safetySettings for image gen + }; return { project: projectId, model: cleanModel, userAgent: "antigravity", requestType: "image_gen", - requestId: `agent-${crypto.randomUUID()}`, - request: { - contents, - generationConfig: { - temperature: 1.0, - topP: 0.95, - topK: 40, - maxOutputTokens: 8192, - imageConfig, - }, - sessionId, - // No tools, no systemInstruction, no safetySettings for image gen - }, + requestId: buildIdeRequestId({ body, request, credentials, model: cleanModel, requestType: "image_gen" }), + request, }; } @@ -222,6 +245,18 @@ export class AntigravityExecutor extends BaseExecutor { // Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; stripBlacklisted(requestWithoutTools); + + // Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from + // flagging the request and immediately blocking it with a 429 Quota Exhausted response. + if (requestWithoutTools.systemInstruction?.parts) { + const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; + for (const part of requestWithoutTools.systemInstruction.parts) { + if (typeof part.text === "string" && part.text.includes(oldText)) { + part.text = part.text.split(oldText).join(""); + } + } + } + const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; @@ -245,10 +280,10 @@ export class AntigravityExecutor extends BaseExecutor { return { ...body, project: projectId, - model: model, + model: body.model || model, userAgent: "antigravity", requestType: "agent", - requestId: `agent-${crypto.randomUUID()}`, + requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), request: transformedRequest }; } diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 71418deb..a4c017e4 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -3,6 +3,7 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js" import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { dbg } from "../utils/debugLog.js"; import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; +import { resolveOpenAICompatibleApiType } from "../services/provider.js"; /** * BaseExecutor - Base class for provider executors @@ -30,7 +31,7 @@ export class BaseExecutor { if (this.provider?.startsWith?.("openai-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE; const normalized = baseUrl.replace(/\/$/, ""); - const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; + const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } if (this.provider?.startsWith?.("anthropic-compatible-")) { @@ -126,7 +127,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream); + const headers = this.buildHeaders(credentials, stream, url, model); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/codebuddy-cn.js b/open-sse/executors/codebuddy-cn.js index 5f37d015..76ec52ff 100644 --- a/open-sse/executors/codebuddy-cn.js +++ b/open-sse/executors/codebuddy-cn.js @@ -18,6 +18,35 @@ export class CodeBuddyExecutor extends DefaultExecutor { const transformed = super.transformRequest(model, body, stream, credentials); transformed.stream = true; + // Tencent's content filter flags CLI agent system prompts ("You are Claude + // Code, Anthropic's official CLI...") as prompt injection / sensitive content + // and rejects the whole request. Detect agent system prompts (length catch-all + // + identity-marker regex) and replace them with a neutral one, while leaving + // legitimate user system prompts untouched. content may be a string or typed + // blocks ([{type:"text",text}]) depending on the incoming client format, so + // flatten before matching and preserve the original shape on replacement. + const NEUTRAL_PROMPT = "You are a helpful AI assistant that helps with software engineering tasks."; + const AGENT_PATTERN = /you are claude code|claude.?code.+official.+cli|anthropic.+official.+cli|anxthxropic.+official.+cli|you are (?:cursor|windsurf|cline|aider|continue|copilot|cody)|you are an? (?:ai )?(?:coding |code )?agent|cc_entrypoint\s*=\s*(?:cli|vscode|jetbrains|gui)|claude.?code.+issues|give feedback.+claude.?code|you are .{0,30}(?:powerful )?ai agent|orchestration capabilities|OhMyOpenCode|||/i; + const flatten = (content) => + typeof content === "string" + ? content + : Array.isArray(content) + ? content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("\n") + : ""; + if (Array.isArray(transformed.messages)) { + transformed.messages = transformed.messages.map((message) => { + if (!message || message.role !== "system") return message; + const text = flatten(message.content); + if (!text) return message; + if (text.length > 2000 || AGENT_PATTERN.test(text)) { + return typeof message.content === "string" + ? { ...message, content: NEUTRAL_PROMPT } + : { ...message, content: [{ type: "text", text: NEUTRAL_PROMPT }] }; + } + return message; + }); + } + // CodeBuddy only surfaces model reasoning when the request carries the CLI's // OpenAI-style params: reasoning_effort + reasoning_summary:"auto". 9router's // thinking pipeline sets reasoning_effort only when the client asks, and never diff --git a/open-sse/executors/codebuddy-intl.js b/open-sse/executors/codebuddy-intl.js new file mode 100644 index 00000000..bb99ff47 --- /dev/null +++ b/open-sse/executors/codebuddy-intl.js @@ -0,0 +1,44 @@ +import { DefaultExecutor } from "./default.js"; + +/** + * CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions + * + * Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn: + * non-stream requests are rejected, and reasoning is surfaced only when the + * request carries the IDE's OpenAI-style reasoning params. Force stream and + * mirror reasoning_summary exactly like CodeBuddyExecutor. + */ +export class CodeBuddyIntlExecutor extends DefaultExecutor { + constructor() { + super("codebuddy-intl"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + transformed.stream = true; + + const eff = transformed.reasoning_effort; + if (eff === "none" || eff === "off") { + delete transformed.reasoning_effort; + } else if (eff) { + transformed.reasoning_summary = "auto"; + } + + // CodeBuddy rejects plain OpenAI shape (11101 invalid request): needs a + // leading system prompt + user content as typed blocks, not a bare string. + const source = Array.isArray(transformed.messages) ? transformed.messages : []; + transformed.messages = [{ role: "system", content: "You are CodeBuddy Code." }]; + for (const message of source) { + if (!message || typeof message !== "object" || ["system", "developer"].includes(message.role)) continue; + if (message.role === "user" && typeof message.content === "string") { + transformed.messages.push({ ...message, content: [{ type: "text", text: message.content }] }); + } else { + transformed.messages.push({ ...message }); + } + } + + return transformed; + } +} + +export default CodeBuddyIntlExecutor; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index e19b0787..4d9acbd2 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -8,13 +8,22 @@ import { import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js"; import { getModelUpstreamId } from "../config/providerModels.js"; -import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; +import { getThinkingLevels } from "../providers/thinkingLevels.js"; +import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { dbg } from "../utils/debugLog.js"; import { resolveSessionId } from "../utils/sessionManager.js"; -// SSE error patterns inside 200-OK body that should trigger retry as if 503 -const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; -const CODEX_SSE_PEEK_BYTES = 4096; +// SSE error patterns inside 200-OK bodies. Some retry same account first; capacity rotates accounts. +const CODEX_SSE_RETRY_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; +const CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS = ["selected model is at capacity", "model_at_capacity"]; +const CODEX_SSE_USER_OUTPUT_PATTERNS = [ + "event: response.output_text.delta", + "event: response.function_call_arguments.delta", + '"type":"response.output_text.delta"', + '"type":"response.function_call_arguments.delta"', +]; +const CODEX_SSE_PEEK_BYTES = 256 * 1024; +const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model."; // Server-generated item id prefixes that Codex /responses cannot resolve when store=false const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; @@ -116,6 +125,66 @@ function resolveCacheSessionId(body, credentials) { }); } +function normalizeReasoningEffort(model, value) { + const supportedLevels = getThinkingLevels("codex", model); + if (supportedLevels?.includes(value)) return value; + if (value === "ultra" && supportedLevels?.includes("max")) return "max"; + if (value === "max" || value === "ultra") return "xhigh"; + return value; +} + +function findNestedMessage(value, depth = 0) { + if (!value || depth > 6 || typeof value === "string") return null; + if (Array.isArray(value)) { + for (const item of value) { + const found = findNestedMessage(item, depth + 1); + if (found) return found; + } + return null; + } + if (typeof value !== "object") return null; + if (typeof value.message === "string" && value.message.trim()) return value.message; + if (typeof value.error?.message === "string" && value.error.message.trim()) return value.error.message; + if (typeof value.response?.error?.message === "string" && value.response.error.message.trim()) return value.response.error.message; + for (const child of Object.values(value)) { + const found = findNestedMessage(child, depth + 1); + if (found) return found; + } + return null; +} + +function extractSseErrorMessage(text, fallback) { + const exact = text?.match(/Selected model is at capacity\. Please try a different model\./i)?.[0]; + if (exact) return exact; + + for (const line of String(text || "").split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + try { + const message = findNestedMessage(JSON.parse(data)); + if (message) return message; + } catch { + // Ignore non-JSON SSE data lines. + } + } + + return fallback || CODEX_MODEL_CAPACITY_MESSAGE; +} + +function codexSseErrorResponse(status, message) { + return new Response(JSON.stringify({ + error: { + message, + type: status >= 500 ? "server_error" : "invalid_request_error", + code: status === HTTP_STATUS.SERVICE_UNAVAILABLE ? "service_unavailable" : "upstream_error", + } + }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing @@ -135,10 +204,17 @@ export class CodexExecutor extends BaseExecutor { headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; // Identify client type to Codex backend (matches official codex CLI) if (!headers["originator"]) headers["originator"] = "codex_cli_rs"; - // Workspace binding header — improves account scope + cache affinity - const workspaceId = credentials?.providerSpecificData?.workspaceId; - if (typeof workspaceId === "string" && workspaceId && !headers["chatgpt-account-id"]) { - headers["chatgpt-account-id"] = workspaceId; + // Account/workspace binding header — required when multiple Codex accounts + // are configured. OAuth import stores ChatGPT account ID as chatgptAccountId; + // older/custom rows may use workspaceId/accountId. Prefer explicit workspaceId + // but fall back to chatgptAccountId so requests don't cross-bind to the wrong + // OpenAI account and surface as token_invalid after adding another account. + const accountId = + credentials?.providerSpecificData?.workspaceId || + credentials?.providerSpecificData?.chatgptAccountId || + credentials?.providerSpecificData?.accountId; + if (typeof accountId === "string" && accountId && !headers["ChatGPT-Account-ID"]) { + headers["ChatGPT-Account-ID"] = accountId; } return headers; } @@ -198,7 +274,7 @@ export class CodexExecutor extends BaseExecutor { let attempt = 0; while (true) { const result = await super.execute(args); - const peek = await this._peekSseOverloaded(result.response); + const peek = await this._peekSseTransientError(result.response); if (!peek.matched) { // Replace body with re-assembled stream (prefix bytes already read + rest) if (peek.replacementBody) { @@ -210,48 +286,57 @@ export class CodexExecutor extends BaseExecutor { } return result; } + if (peek.accountFallback) { + args.log?.warn?.("RETRY", `CODEX | SSE account fallback "${peek.message}"`); + result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || CODEX_MODEL_CAPACITY_MESSAGE); + return result; + } if (attempt >= attempts) { args.log?.warn?.("RETRY", `CODEX | SSE overloaded "${peek.matched}" — retries exhausted (${attempt}/${attempts})`); - // Out of retries → return with replacement body so client gets the error - if (peek.replacementBody) { - result.response = new Response(peek.replacementBody, { - status: result.response.status, - statusText: result.response.statusText, - headers: result.response.headers, - }); - } + result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || peek.matched); return result; } attempt++; args.log?.debug?.("RETRY", `CODEX | SSE "${peek.matched}" retry ${attempt}/${attempts} after ${delayMs / 1000}s`); dbg("CODEX", `SSE overloaded "${peek.matched}" → retry ${attempt}/${attempts} in ${delayMs}ms`); - try { await result.response.body?.cancel?.(); } catch { /* noop */ } await new Promise(r => setTimeout(r, delayMs)); } } - // Peek first N bytes of SSE body to detect upstream "overloaded" errors. - // Returns { matched: string|null, replacementBody: ReadableStream|null }. - // Caller MUST use replacementBody (original body has been read). - async _peekSseOverloaded(response) { - if (!response || !response.ok || !response.body) return { matched: null, replacementBody: null }; + // Peek first N bytes of SSE body to detect upstream transient errors. + // Returns { matched: string|null, message: string|null, accountFallback: boolean, replacementBody: ReadableStream|null }. + // Caller must use replacementBody when no error matched (original body has been read). + async _peekSseTransientError(response) { + if (!response || !response.ok || !response.body) return { matched: null, message: null, accountFallback: false, replacementBody: null }; const reader = response.body.getReader(); const decoder = new TextDecoder(); const chunks = []; let text = ""; let matched = null; + let accountFallback = false; try { while (text.length < CODEX_SSE_PEEK_BYTES) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); text += decoder.decode(value, { stream: true }); - const hit = CODEX_SSE_OVERLOADED_PATTERNS.find(p => text.includes(p)); - if (hit) { matched = hit; break; } + const lowerText = text.toLowerCase(); + const accountHit = CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS.find(p => lowerText.includes(p)); + if (accountHit) { matched = accountHit; accountFallback = true; break; } + const retryHit = CODEX_SSE_RETRY_PATTERNS.find(p => lowerText.includes(p)); + if (retryHit) { matched = retryHit; break; } + if (CODEX_SSE_USER_OUTPUT_PATTERNS.some(p => lowerText.includes(p))) break; } } catch (e) { dbg("CODEX", `peek read error: ${e.message}`); } + + if (matched) { + try { await reader.cancel(); } catch { /* noop */ } + try { reader.releaseLock(); } catch { /* noop */ } + return { matched, message: extractSseErrorMessage(text, matched), accountFallback, replacementBody: null }; + } + reader.releaseLock(); // Re-assemble stream: prefix chunks + remaining upstream body @@ -273,7 +358,7 @@ export class CodexExecutor extends BaseExecutor { try { upstreamReader?.cancel(reason); } catch { /* noop */ } }, }); - return { matched, replacementBody }; + return { matched: null, message: null, accountFallback: false, replacementBody }; } // Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise @@ -347,7 +432,7 @@ export class CodexExecutor extends BaseExecutor { // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) - const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh']; + const effortLevels = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']; let modelEffort = null; for (const level of effortLevels) { if (body.model.endsWith(`-${level}`)) { @@ -360,10 +445,11 @@ export class CodexExecutor extends BaseExecutor { // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) if (!body.reasoning) { - const effort = body.reasoning_effort || modelEffort || 'low'; + const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low'); body.reasoning = { effort, summary: "auto" }; - } else if (!body.reasoning.summary) { - body.reasoning.summary = "auto"; + } else { + body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort); + if (!body.reasoning.summary) body.reasoning.summary = "auto"; } delete body.reasoning_effort; @@ -391,6 +477,9 @@ export class CodexExecutor extends BaseExecutor { delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it delete body.previous_response_id; // store=false → backend can't resolve previous resp; avoid 404 + if (body.service_tier === "fast") body.service_tier = "priority"; + if (body.service_tier && body.service_tier !== "priority") delete body.service_tier; + // Final allowlist filter — strip any unknown field that could trigger upstream "routing_unsupported" for (const k of Object.keys(body)) { if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k]; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index fe06d80d..0aefc623 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -1,18 +1,22 @@ import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; +import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { generateCursorBody, + encodeField, + wrapConnectRPCFrame, + decodeMessage, parseConnectRPCFrame, extractTextFromResponse } from "../utils/cursorProtobuf.js"; import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; -import { chatChunkSse } from "../utils/sse.js"; +import { chatChunkSse, sseChunk } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; +import crypto from "crypto"; // Detect cloud environment const isCloudEnv = () => { @@ -38,6 +42,130 @@ const COMPRESS_FLAG = { GZIP_TRAILER: 0x03 }; +const AGENT_RUN_PATH = "/agent.v1.AgentService/Run"; +const PROTOBUF_LEN = 2; +const PROTOBUF_VARINT = 0; + +function concatBuffers(...parts) { + const length = parts.reduce((total, part) => total + part.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0); + +function textFromContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); +} + +function isAgentTextRequest(body) { + // Many compatible clients always attach their built-in tool schemas, even + // for a normal text turn. Cursor's retired ChatService rejects those + // requests; AgentService can still answer the text turn, so ignore schemas + // here. A real tool-call/result conversation is kept on the legacy path + // until its AgentService tool protocol is implemented. + return Array.isArray(body?.messages) && body.messages.every((message) => { + if (message?.tool_calls?.length || message?.role === "tool") return false; + return typeof message?.content === "string" + || Array.isArray(message?.content) && message.content.every((part) => part?.type === "text"); + }); +} + +function encodeHistoryMessage(message) { + const content = textFromContent(message?.content); + if (!content) return null; + + // ConversationHistoryMessage.user / .assistant -> repeated content -> text. + const text = agentString(1, content); + if (message.role === "assistant") { + return agentMessage(2, agentMessage(1, agentMessage(1, text))); + } + return agentMessage(1, agentMessage(1, agentMessage(1, text))); +} + +function buildAgentRunFrame(messages, model) { + const system = messages + .filter((message) => message?.role === "system") + .map((message) => textFromContent(message.content)) + .filter(Boolean) + .join("\n\n"); + const chatMessages = messages.filter((message) => message?.role !== "system"); + const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user"); + const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1); + const history = chatMessages + .slice(0, currentIndex >= 0 ? currentIndex : -1) + .map(encodeHistoryMessage) + .filter(Boolean); + const userText = textFromContent(current?.content) || "Continue."; + + // agent.v1.UserMessageAction.user_message and its optional history. + const userMessage = concatBuffers( + agentString(1, userText), + agentString(2, crypto.randomUUID()), + ); + const conversationHistory = history.length + ? concatBuffers(...history.map((entry) => agentMessage(1, entry))) + : null; + const userAction = concatBuffers( + agentMessage(1, userMessage), + ...(conversationHistory ? [agentMessage(7, conversationHistory)] : []), + ); + const conversationAction = agentMessage(1, userAction); + const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true)); + const runRequest = concatBuffers( + // An empty ConversationStateStructure starts a fresh local agent session. + agentMessage(1, new Uint8Array()), + agentMessage(2, conversationAction), + ...(system ? [agentString(8, system)] : []), + agentMessage(9, requestedModel), + ); + + // agent.v1.AgentClientMessage.run_request. + return wrapConnectRPCFrame(agentMessage(1, runRequest)); +} + +function extractAgentString(message, field) { + const value = message?.get(field)?.[0]?.value; + return value ? Buffer.from(value).toString("utf8") : ""; +} + +function decodeAgentFrames(buffer, onFrame) { + let pending = Buffer.from(buffer || []); + while (pending.length >= 5) { + const flags = pending[0]; + const length = pending.readUInt32BE(1); + if (pending.length < 5 + length) break; + let payload = pending.subarray(5, 5 + length); + pending = pending.subarray(5 + length); + if (flags & COMPRESS_FLAG.GZIP) { + payload = zlib.gunzipSync(payload); + } + if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload); + } + return pending; +} + +function createRequestContextResponse() { + // AgentService asks every run for client context. 9router has no IDE file + // context, so acknowledge with an empty RequestContext. + const requestContextSuccess = agentMessage(1, new Uint8Array()); + const requestContextResult = agentMessage(1, requestContextSuccess); + const execClientMessage = agentMessage(10, requestContextResult); + return wrapConnectRPCFrame(agentMessage(2, execClientMessage)); +} + const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; const debugLog = (...args) => { if (CURSOR_STREAM_DEBUG) console.log(...args); @@ -253,7 +381,304 @@ export class CursorExecutor extends BaseExecutor { }); } + /** + * AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks + * HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex. + */ + openAgentHttp2Stream(url, headers, signal) { + if (!http2) { + throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)"); + } + + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunkQueue = []; + let waiting = null; + let ended = false; + let streamError = null; + let req = null; + + const wake = (result) => { + if (!waiting) return; + const resolve = waiting; + waiting = null; + resolve(result); + }; + + const fail = (error) => { + if (streamError) return; + streamError = error; + ended = true; + wake(null); + }; + + const close = () => { + try { req?.destroy(); } catch {} + try { client.close(); } catch {} + }; + + client.on("error", fail); + + req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("error", fail); + req.on("data", (chunk) => { + if (waiting) wake({ value: chunk, done: false }); + else chunkQueue.push(chunk); + }); + req.on("end", () => { + ended = true; + wake({ value: undefined, done: true }); + }); + + if (signal) { + const onAbort = () => { + fail(new Error("Request aborted")); + close(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + const responseHeaders = new Promise((resolve, reject) => { + const onEarlyError = (error) => reject(error); + client.once("error", onEarlyError); + req.once("error", onEarlyError); + req.once("response", (hdrs) => { + client.off("error", onEarlyError); + req.off("error", onEarlyError); + resolve(hdrs); + }); + }); + + return { + responseHeaders, + write(frame) { + if (req && !req.destroyed) req.write(Buffer.from(frame)); + }, + end() { + try { if (req && !req.destroyed) req.end(); } catch {} + }, + close, + async read() { + if (chunkQueue.length) return { value: chunkQueue.shift(), done: false }; + if (ended) { + if (streamError) throw streamError; + return { value: undefined, done: true }; + } + const result = await new Promise((resolve) => { waiting = resolve; }); + if (streamError) throw streamError; + return result || { value: undefined, done: true }; + }, + }; + } + + async executeAgent({ model, body, stream, credentials, signal }) { + const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint; + if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured"); + + const url = `${agentEndpoint}${AGENT_RUN_PATH}`; + const headers = this.buildHeaders(credentials); + const requestController = new AbortController(); + if (signal?.addEventListener) { + signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true }); + } + + let session; + try { + session = this.openAgentHttp2Stream(url, headers, requestController.signal); + session.write(buildAgentRunFrame(body.messages || [], model)); + } catch (error) { + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + let responseHeaders; + try { + responseHeaders = await session.responseHeaders; + } catch (error) { + session.close(); + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + const status = Number(responseHeaders[":status"] || 0); + if (status !== 200) { + let errorText = ""; + try { + while (true) { + const { done, value } = await session.read(); + if (done) break; + errorText += Buffer.from(value).toString("utf8"); + } + } catch {} + session.close(); + return { + response: new Response(JSON.stringify({ + error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" }, + }), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + // The Claude SSE translator derives Anthropic's message ID by stripping + // `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form + // so strict clients such as Claude Code accept the completed stream. + const responseId = `chatcmpl-msg_${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let pending = Buffer.alloc(0); + let finished = false; + + const consume = async (onEvent) => { + try { + while (!finished) { + const { done, value } = await session.read(); + if (done) break; + pending = Buffer.concat([pending, Buffer.from(value)]); + pending = decodeAgentFrames(pending, (payload) => { + // A single read can carry several frames; once the turn is over the + // rest of the batch must not reach the already-closed controller. + if (finished) return; + const serverMessage = decodeMessage(payload); + + // agent.v1.AgentServerMessage.interaction_update + if (serverMessage.has(1)) { + const update = decodeMessage(serverMessage.get(1)[0].value); + if (update.has(1)) { + const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1); + if (textDelta) onEvent({ type: "text", value: textDelta }); + } + // Cursor's AgentService emits internal reasoning without the + // cryptographic signature required by Anthropic thinking blocks. + // Forwarding it makes strict Anthropic clients (Claude Code) + // discard or wait on an otherwise complete response. Keep the + // reasoning upstream-only and emit the normal answer text. + if (update.has(14)) { + finished = true; + onEvent({ type: "done" }); + } + } + + // AgentService requests IDE context before producing a response. + // Return an empty context; 9router is not coupled to an editor. + if (serverMessage.has(2)) { + const execRequest = decodeMessage(serverMessage.get(2)[0].value); + if (execRequest.has(10)) { + session.write(createRequestContextResponse()); + } else { + // Every other ExecServerMessage variant is an editor-backed tool + // (shell, read, write, …) that 9router cannot service. Fail the + // turn rather than narrating protocol state as assistant text. + debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`); + finished = true; + onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); + } + } + }); + } + } finally { + try { session.end(); } catch {} + try { session.close(); } catch {} + if (!finished) onEvent({ type: "done" }); + } + }; + + if (stream === false) { + let content = ""; + let reasoning = ""; + let agentError = null; + await consume((event) => { + if (event.type === "text") content += event.value; + else if (event.type === "thinking") reasoning += event.value; + else if (event.type === "error") agentError = event.value; + }); + if (agentError) { + return { + response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), { + status: HTTP_STATUS.BAD_REQUEST, + headers: { "Content-Type": "application/json" }, + }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + return { + response: new Response(JSON.stringify({ + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }], + usage: estimateUsage(body, content.length, FORMATS.OPENAI), + }), { headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + const encoder = new TextEncoder(); + const responseStream = new ReadableStream({ + start(controller) { + consume((event) => { + if (event.type === "text") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } }))); + } else if (event.type === "thinking") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); + } else if (event.type === "error") { + // An SSE error frame, not a content delta: a protocol failure must not + // be rendered to the user as the assistant's reply, and downstream + // usage tracking must not record the turn as a success. + controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } else if (event.type === "done") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } + }).catch((error) => controller.error(error)); + }, + cancel() { + requestController.abort(); + }, + }); + + return { + response: new Response(responseStream, { headers: SSE_HEADERS }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + if (isAgentTextRequest(body)) { + try { + return await this.executeAgent({ model, body, stream, credentials, signal }); + } catch (error) { + return { + response: new Response(JSON.stringify({ + error: { message: error.message, type: "connection_error", code: "" }, + }), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`, + headers: {}, + transformedBody: body, + }; + } + } + const url = this.buildUrl(); const headers = this.buildHeaders(credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 96a230b3..92c78e92 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -1,9 +1,9 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; -import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; +import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js"; +import { resolveOpenAICompatibleApiType } from "../services/provider.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../shared/clineAuth.js"; -import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; @@ -38,24 +38,10 @@ function applyAuth(headers, desc, credentials) { // Provider-specific header quirks kept as small hooks (not pure auth). const HEADER_HOOKS = { - kimiHeaders: (h) => Object.assign(h, buildKimiHeaders()), + // Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID) + kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)), clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)), kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, - claudeOverlay: (h) => { - const cached = getCachedClaudeHeaders(); - if (!cached) return; - for (const lcKey of Object.keys(cached)) { - const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase()); - if (lcKey === "anthropic-beta") { - const staticBetaStr = h[titleKey] || h[lcKey] || ""; - const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean)); - for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f); - cached[lcKey] = Array.from(flags).join(","); - } - if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey]; - } - Object.assign(h, cached); - }, }; // Config-driven OAuth refresh grants — derived from registry oauth.refresh. @@ -124,7 +110,7 @@ export class DefaultExecutor extends BaseExecutor { if (this.provider?.startsWith?.("openai-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE; const normalized = baseUrl.replace(/\/$/, ""); - const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; + const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } if (this.provider?.startsWith?.("anthropic-compatible-")) { @@ -160,14 +146,18 @@ export class DefaultExecutor extends BaseExecutor { return BEARER; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url, model) { const rt = credentials?.runtimeTransport; const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) }; const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor(); - // Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token. + // Hooks run BEFORE auth so dynamic overlays can't clobber the token. for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials); applyAuth(headers, desc, credentials); + if (this.provider === "claude" && model) { + headers["Anthropic-Beta"] = selectAnthropicBeta(model); + } + // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams if (this.provider?.startsWith?.("anthropic-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; @@ -221,13 +211,13 @@ export class DefaultExecutor extends BaseExecutor { const refreshers = { claude: () => this.refreshFromGrant(credentials, proxyOptions), codex: () => this.refreshFromGrant(credentials, proxyOptions), - qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions), iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions), gemini: () => this.refreshFromGrant(credentials, proxyOptions), kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), clinepass: () => this.refreshCline(credentials.refreshToken, proxyOptions), - "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), + kimi: () => this.refreshKimi(credentials, proxyOptions), + "kimi-coding": () => this.refreshKimi(credentials, proxyOptions), kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions) }; @@ -307,16 +297,20 @@ export class DefaultExecutor extends BaseExecutor { return { accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; } - async refreshKimiCoding(refreshToken, proxyOptions = null) { - const kimiHeaders = buildKimiHeaders(); - const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, { + // CLIProxyAPI DeviceFlowClient.RefreshToken — form body + X-Msh-* headers + stable device_id + async refreshKimi(credentials, proxyOptions = null) { + const refreshToken = credentials.refreshToken; + const cfg = PROVIDERS.kimi || PROVIDERS["kimi-coding"]; + if (!cfg?.refreshUrl || !cfg?.clientId) return null; + const kimiHeaders = buildKimiHeaders(credentials?.providerSpecificData?.deviceId); + const response = await proxyAwareFetch(cfg.refreshUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", ...kimiHeaders }, - body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS["kimi-coding"].clientId }) + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: cfg.clientId }) }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); diff --git a/open-sse/executors/devin-cli.js b/open-sse/executors/devin-cli.js new file mode 100644 index 00000000..7cb35ff1 --- /dev/null +++ b/open-sse/executors/devin-cli.js @@ -0,0 +1,847 @@ +/** + * DevinCliExecutor — routes completions through the official Devin CLI binary + * via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio. + * + * Protocol flow: + * 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search). + * Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode. + * 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt. + * 3. Receive: session/update notifications (agent_message_chunk = reply text, + * tool_call/tool_call_update = built-in tool invocations, surfaced as text). + * When devin calls a client-tool from the exposed MCP ("Calling mcp_X from + * clientTools"), it is bridged to an OpenAI tool_use and the turn ends. + * 4. Emit deltas as OpenAI-compatible SSE chunks. + * 5. Kill subprocess on _cognition.ai/agent_stopped or error. + * + * Auth: noAuth — the subprocess inherits the parent env and uses credentials + * stored by `devin auth login` (~/.local/share/devin/credentials.toml). + * + * Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor } from "./base.js"; + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +function resolveDevinBin() { + // 1. Explicit override + const envBin = process.env.CLI_DEVIN_BIN?.trim(); + if (envBin) return envBin; + + const isWin = process.platform === "win32"; + const home = os.homedir(); + + // 2. Known installer / package-manager locations. spawn uses shell:false on + // macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew, + // Scoop, etc. when the server runs detached (tray/daemon/launchd) without + // a login shell — probe these explicitly before falling back to PATH. + const candidates = isWin + ? [ + // Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), // pipx / user install + "/opt/homebrew/bin/devin", // Homebrew (Apple Silicon) + "/usr/local/bin/devin", // Homebrew (Intel) / manual + "/usr/bin/devin", + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + // 3. Fallback — rely on process.env.PATH + return isWin ? "devin.exe" : "devin"; +} + +// ─── ACP JSON-RPC helper ──────────────────────────────────────────────────── + +function rpc(method, params, id) { + const msg = { jsonrpc: "2.0", method, params }; + if (id !== undefined) msg.id = id; + return JSON.stringify(msg) + "\n"; +} + +// ─── Client-tools → MCP bridge ─────────────────────────────────────────────── +// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas. +// body.tools are exposed as a stdio MCP server "clientTools" so devin can call +// them. When devin calls one, we emit OpenAI tool_use and end the turn; the +// client executes and returns tool_result on the next request. That next request +// re-spawns with the full history (including tool_calls + tool results) and +// seeds the MCP server with those results so a re-call gets the real data. +// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS. + +const CLIENT_TOOLS_MCP_SCRIPT = ` +import readline from "node:readline"; +const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]"); +const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}"); +const rl = readline.createInterface({ input: process.stdin }); +function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); } +rl.on("line", (line) => { + let m; try { m = JSON.parse(line); } catch { return; } + if (m.method === "initialize") { + send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } }); + } else if (m.method === "tools/list") { + send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } }); + } else if (m.method === "tools/call") { + const name = m.params?.name || ""; + const seeded = RESULTS[name]; + const text = seeded !== undefined + ? String(seeded) + : "(awaiting client tool_result)"; + process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n"); + send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } }); + } +}); +`.trimStart(); + +function ensureClientToolsScript() { + const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs"); + // Always rewrite so script upgrades land without a process restart. + fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT); + return scriptPath; +} + +// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}]) +// to MCP tool declarations ([{name,description,inputSchema}]). +// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we +// add it here and strip it back when bridging the call to the client. +const MCP_TOOL_PREFIX = "mcp_"; +function toMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name; +} +function fromMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name; +} + +function buildClientToolsMcp(tools, resultMap) { + const mcpTools = []; + for (const t of tools) { + if (!t) continue; + const f = t.function || t; + if (!f?.name) continue; + mcpTools.push({ + name: toMcpToolName(f.name), + description: f.description || "", + inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} }, + }); + } + if (!mcpTools.length) return null; + const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) }; + if (resultMap && Object.keys(resultMap).length) { + env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap); + } + return { + command: process.execPath, + args: [ensureClientToolsScript()], + env, + }; +} + +// Extract tool_result content keyed by MCP tool name (mcp_). +// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content. +function extractClientToolResults(messages) { + const idToMcpName = new Map(); + const results = {}; + for (const m of messages) { + if (m?.role === "assistant" && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + const name = tc?.function?.name || tc?.name; + if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name)); + } + } + // Claude-style tool_use blocks in content + if (m?.role === "assistant" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_use" && b.id && b.name) { + idToMcpName.set(b.id, toMcpToolName(b.name)); + } + } + } + if (m?.role === "tool" && m.tool_call_id) { + const mcpName = idToMcpName.get(m.tool_call_id); + if (mcpName) { + results[mcpName] = + typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + } + } + // Claude-style tool_result blocks in user content + if (m?.role === "user" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_result" && b.tool_use_id) { + const mcpName = idToMcpName.get(b.tool_use_id); + if (mcpName) { + const c = b.content; + results[mcpName] = + typeof c === "string" ? c : JSON.stringify(c ?? ""); + } + } + } + } + } + return results; +} + +// Resolve workspace cwd from client request (Codex/CLI env context, body fields). +// Prefer an absolute existing path so agent file tools hit the user's project +// instead of os.tmpdir() (which made relative create/delete inconsistent). +function resolveWorkspaceCwd(body) { + const candidates = []; + const push = (v) => { + if (typeof v === "string" && v.trim()) candidates.push(v.trim()); + }; + push(body?.cwd); + push(body?.working_directory); + push(body?.workdir); + push(body?.workspace); + push(body?.metadata?.cwd); + push(body?.metadata?.working_directory); + + const scanText = (text) => { + if (typeof text !== "string") return; + for (const m of text.matchAll(/\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]); + }; + const scanMessages = (msgs) => { + if (!Array.isArray(msgs)) return; + for (const msg of msgs) { + if (!msg) continue; + if (typeof msg.content === "string") scanText(msg.content); + else if (Array.isArray(msg.content)) { + for (const p of msg.content) { + if (typeof p === "string") scanText(p); + else if (p && typeof p === "object") { + scanText(p.text); + scanText(p.input_text); + scanText(p.content); + } + } + } + // Responses API input items + if (typeof msg === "string") scanText(msg); + if (msg.type === "message" && Array.isArray(msg.content)) { + for (const p of msg.content) scanText(p?.text || p?.input_text); + } + } + }; + scanMessages(body?.messages); + scanMessages(body?.input); + + for (const c of candidates) { + try { + if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) { + return c; + } + } catch { + /* ignore */ + } + } + return os.tmpdir(); +} + +// ─── Multi-turn message → single prompt builder ───────────────────────────── + +function buildPromptText(messages) { + // Inline the whole conversation so the model has full context, including + // prior tool_calls / tool_results so it can continue after a client round-trip. + const lines = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (!p || typeof p !== "object") continue; + if (p.type === "text") text += String(p.text || ""); + else if (p.type === "tool_use") { + text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`; + } else if (p.type === "tool_result") { + const c = + typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? ""); + text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`; + } + } + } + // OpenAI tool_calls on assistant messages + if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) { + const parts = m.tool_calls.map((tc) => { + const name = tc.function?.name || tc.name || "tool"; + const args = tc.function?.arguments ?? tc.arguments ?? {}; + const argStr = typeof args === "string" ? args : JSON.stringify(args); + return `[Tool call ${name} id=${tc.id}]\n${argStr}`; + }); + text = [text, ...parts].filter(Boolean).join("\n\n"); + } + // OpenAI role=tool messages + if (role === "tool") { + const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`; + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else if (role === "tool") { + lines.push(`[Tool]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +// ─── DevinCliExecutor ───────────────────────────────────────────────────────── + +export class DevinCliExecutor extends BaseExecutor { + constructor() { + super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" }); + } + + buildUrl() { + return "devin://acp/stdio"; + } + + buildHeaders() { + return {}; + } + + transformRequest() { + return null; + } + + async execute({ model, body, credentials, signal, log }) { + const b = body ?? {}; + const messages = Array.isArray(b.messages) + ? b.messages + : Array.isArray(b.input) + ? b.input + : []; + const promptText = buildPromptText(messages); + const workspaceCwd = resolveWorkspaceCwd(b); + const devinBin = resolveDevinBin(); + + log?.info?.( + "DEVIN", + `devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}` + ); + + // Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format): + // {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}} + // Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP + // server so devin can invoke client-defined tools (bridged back in Phase 2). + // When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so + // the agent auto-connects them (session/new mcpServers alone doesn't spawn + // them — see ACP mcp/connect, still unstable). Cleaned up on finish. + // NOTE: this replaces the user's global devin MCP config for the subprocess. + let mcpConfigDir = null; + const mcpServers = {}; + const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim(); + if (mcpJson) { + try { + Object.assign(mcpServers, JSON.parse(mcpJson)); + } catch (e) { + log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`); + } + } + const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : []; + const clientToolResults = extractClientToolResults(messages); + const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults); + const hasClientTools = !!clientToolsMcp; + if (clientToolsMcp) { + mcpServers["clientTools"] = clientToolsMcp; + const seeded = Object.keys(clientToolResults).length; + log?.info?.( + "DEVIN", + `exposing ${clientTools.length} client tool(s) as MCP` + + (seeded ? ` (seeded ${seeded} result(s))` : "") + ); + } + if (Object.keys(mcpServers).length) { + try { + mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-")); + const cfgDev = path.join(mcpConfigDir, "devin"); + fs.mkdirSync(cfgDev, { recursive: true }); + fs.writeFileSync( + path.join(cfgDev, "config.json"), + JSON.stringify({ mcpServers }) + ); + log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`); + } catch (e) { + log?.info?.("DEVIN", `mcp config write failed: ${e.message}`); + mcpConfigDir = null; + } + } + const cleanupMcp = () => { + if (!mcpConfigDir) return; + try { + fs.rmSync(mcpConfigDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + mcpConfigDir = null; + }; + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data) => controller.enqueue(enc.encode(data)); + + // Inherit the parent environment so devin resolves stored CLI credentials + // (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT + // inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key + // overrides stored creds and makes devin return -32000 "invalid api key". + const env = { ...process.env }; + // Auto-approve tool execution so the agent doesn't block waiting for a + // session/request_permission response we never send (default mode would + // hang the stream on the first shell/exec tool call). Override via env. + // WARNING: bypass lets the agent run shell/modify FS unattended — local only. + env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass"; + if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir; + + // Agent type: default (omitted) = full agent with built-in tools + // (fs/shell/search) so the model can actually perform tasks. Override to + // `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer, + // tool-less mode. WARNING: the default agent can run shell commands and + // modify the filesystem on the host running 9router — only expose locally. + const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim(); + const acpArgs = ["acp"]; + if (agentType) acpArgs.push("--agent-type", agentType); + + // Spawn in the client workspace cwd (from env context) so built-in + // file tools create/delete relative paths in the user's project. + // MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/. + const child = spawn(devinBin, acpArgs, { + env, + cwd: workspaceCwd, + stdio: ["pipe", "pipe", "pipe"], + // On Windows, devin.exe may need shell resolution + shell: process.platform === "win32", + }); + + let spawnError = null; + let stdinClosed = false; + + child.on("error", (err) => { + spawnError = err; + const msg = + err.message.includes("ENOENT") || err.message.includes("not found") + ? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.` + : `Devin CLI spawn error: ${err.message}`; + emit( + `data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n` + ); + emit("data: [DONE]\n\n"); + controller.close(); + }); + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + }); + } + + // ── JSON-RPC state machine ────────────────────────────────────────── + let idCounter = 1; + let sessionId = null; + let initDone = false; + let sessionCreated = false; + let promptSent = false; + const responseId = `chatcmpl-devin-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let roleEmitted = false; + let totalText = ""; + let finished = false; + + const sendRpc = (method, params) => { + if (stdinClosed || child.stdin.destroyed) return; + const id = idCounter++; + try { + child.stdin.write(rpc(method, params, id)); + } catch { + /* ignore write errors after close */ + } + return id; + }; + + // Emit a content delta as an OpenAI-compatible SSE chunk (handles the + // leading role chunk once). + const emitDelta = (delta) => { + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + totalText += delta; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + }; + + // Emit an OpenAI tool_call delta (function calling). Ends the turn with + // finish_reason "tool_calls" so the client executes and returns tool_result. + let toolUseEmitted = false; + // ACP tool_call is upsert-by-id: the first event has title, a later update + // may only carry rawInput (title omitted). Track pending client-tool calls. + const pendingClientTools = new Map(); // toolCallId → original tool name + const emitToolUse = (toolName, args, toolCallId) => { + const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {}); + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: toolCallId, + type: "function", + function: { name: toolName, arguments: argsStr }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + }; + + const finish = (error, finishReason = "stop") => { + if (finished) return; + finished = true; + + if (error) { + emit( + `data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n` + ); + } else { + // Emit finish chunk + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(totalText.length / 4), + total_tokens: Math.ceil((promptText.length + totalText.length) / 4), + estimated: true, + }, + })}\n\n` + ); + } + emit("data: [DONE]\n\n"); + + // Gracefully close stdin → devin will exit + try { + if (!stdinClosed) { + stdinClosed = true; + child.stdin.end(); + } + } catch { + /* ignore */ + } + + // Give it 2s to exit cleanly, then SIGKILL + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 2000); + killTimer.unref?.(); + + controller.close(); + cleanupMcp(); + }; + + // ── stdout reader (NDJSON) ────────────────────────────────────────── + let buffer = ""; + + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + // Each ACP message is a newline-terminated JSON line + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; // ignore non-JSON lines (banner text, etc.) + } + + // ── Initialize response ─────────────────────────────────────── + if (!initDone && msg.result !== undefined && !msg.method) { + initDone = true; + // Create session with the client workspace cwd so agent file tools + // resolve relative paths against the project (not /tmp). + // `mcpServers` is required by devin 3000.2.x (must be a sequence); + // omitting it returns -32602 "Invalid params: missing field mcpServers". + sendRpc("session/new", { + cwd: workspaceCwd, + mcpServers: [], + model: model || undefined, + }); + continue; + } + + // ── session/new response → get sessionId ────────────────────── + if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) { + const res = msg.result || {}; + sessionId = res.sessionId || null; + if (!sessionId) { + finish("Devin ACP: session/new returned no sessionId"); + return; + } + sessionCreated = true; + // Send the prompt. devin 3000.2.x expects `prompt` (a sequence), + // not `content` — using `content` returns -32602 "missing field prompt". + promptSent = true; + sendRpc("session/prompt", { + sessionId, + prompt: [{ type: "text", text: promptText }], + }); + continue; + } + + // ── session/prompt response (ack / final result) ──────────── + if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) { + // Devin 3000.2.x only resolves session/prompt with the final result + // (stopReason) after streaming completes. Streaming notifications are + // handled below; nothing to do here unless we never streamed. + if (!roleEmitted) { + const res = msg.result || undefined; + const content = extractResultText(res); + if (content) { + totalText = content; + emitDelta(content); + } + const stopReason = (res && res.stopReason) || ""; + if (stopReason && stopReason !== "cancelled") { + finish(); + return; + } + } + continue; + } + + // ── Permission requests → auto-approve the first allow option ── + // Devi asks before running shell/exec tools; as a headless proxy we + // grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these, + // but some tool kinds still prompt, so handle them here too.) + if (msg.method === "session/request_permission" && msg.id !== undefined) { + const options = msg.params?.options || []; + const allow = + options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0]; + if (allow) { + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { outcome: { outcome: "selected", optionId: allow.optionId } }, + }) + "\n" + ); + } + continue; + } + + // ── Agent stopped notification (devin 3000.2.x stop signal) ─── + if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") { + const cause = msg.params?.cause; + if (cause === "error") { + // devin uses errorMessage on this notification (not message/error). + const errText = + msg.params?.errorMessage || + msg.params?.message || + msg.params?.error || + "Devin agent error"; + finish(String(errText)); + } else { + finish(); + } + return; + } + + // ── Streaming notifications (session/update) ────────────────── + if (msg.method === "session/update" || msg.method === "$/update") { + const params = msg.params; + if (!params) continue; + + // devin 3000.2.x nests the payload under params.update.sessionUpdate; + // older devin used a flat params.type. + const update = params.update || {}; + const type = update.sessionUpdate || params.type; + const contentField = update.content !== undefined ? update.content : params.content; + const deltaText = + typeof contentField === "string" + ? contentField + : contentField?.text ?? params.delta ?? params.text ?? ""; + + // ── Client-tool bridge: devin calling a tool from our exposed MCP ── + // ACP title shape: "Calling mcp_ from clientTools". + // tool_call is upsert-by-id: title may only appear on the first event, + // rawInput on a later tool_call_update. Track pending ids so we don't + // require both fields on the same notification. + if ( + hasClientTools && + !toolUseEmitted && + (type === "tool_call" || type === "tool_call_update") + ) { + const tcId = update.toolCallId; + if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) { + const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/); + const mcpName = nameMatch ? nameMatch[1] : ""; + const origName = fromMcpToolName(mcpName); + if (tcId && origName) pendingClientTools.set(tcId, origName); + } + const origName = tcId ? pendingClientTools.get(tcId) : null; + if (origName && update.rawInput) { + toolUseEmitted = true; + pendingClientTools.delete(tcId); + emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`); + finish(null, "tool_calls"); + return; + } + continue; + } + + if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") { + if (deltaText) emitDelta(deltaText); + } else if (type === "agent_thought_chunk") { + // Internal reasoning — not surfaced to the client. + } else if (type === "message_stop" || type === "stop" || type === "done") { + finish(); + return; + } else if (type === "error") { + finish(String(params.message || params.error || "Devin ACP error")); + return; + } + continue; + } + + // ── Error responses ─────────────────────────────────────────── + if (msg.error) { + finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`); + return; + } + } + }); + + child.stderr.on("data", (chunk) => { + log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (!finished) { + if (code !== 0 && !spawnError) { + finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`); + } else { + finish(); + } + } else { + cleanupMcp(); + } + }); + + // ── Send initialize ─────────────────────────────────────────────── + sendRpc("initialize", { + protocolVersion: "0.3", + clientInfo: { name: "9router", version: "1.0" }, + capabilities: {}, + }); + }, + }); + + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { + model, + cwd: workspaceCwd, + clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean), + clientToolResults: Object.keys(clientToolResults), + mcpServers: Object.keys(mcpServers), + promptLength: Array.isArray(body?.messages) + ? body.messages.length + : Array.isArray(body?.input) + ? body.input.length + : 0, + }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Extract text from a final ACP session/prompt result object across common shapes. +function extractResultText(result) { + // { message: { content: "..." } } + // { messages: [{ content: "..." }] } + // { content: "..." } + // { text: "..." } + if (typeof result.content === "string") return result.content; + if (typeof result.text === "string") return result.text; + const msg = result.message; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} + +export default DevinCliExecutor; diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index 2f4d68ba..208ff8f2 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -4,11 +4,13 @@ import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; -import { initState } from "../translator/index.js"; +import { initState, translateRequest, translateResponse } from "../translator/index.js"; +import { FORMATS } from "../translator/formats.js"; import { parseSSELine, formatSSE } from "../utils/streamHelpers.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; import { SSE_DONE } from "../utils/sseConstants.js"; +import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; import crypto from "crypto"; export class GithubExecutor extends BaseExecutor { @@ -17,6 +19,16 @@ export class GithubExecutor extends BaseExecutor { this.knownCodexModels = new Set(); } + // Claude models get routed to Copilot's Anthropic-native /v1/messages shim (see + // executeWithMessagesEndpoint below) — the only Copilot endpoint that surfaces + // prompt-cache token counts. gpt/gemini/grok models stay on /chat/completions + // (or /responses). Name-pattern check, not a registry field: Copilot's live model + // catalog (services/copilotModels.js) regularly exposes claude-* variants ahead + // of the static registry (registry/github.js). + isClaudeModel(model) { + return /claude/i.test(model || ""); + } + buildUrl(model, stream, urlIndex = 0) { return this.config.baseUrl; } @@ -35,47 +47,20 @@ export class GithubExecutor extends BaseExecutor { "x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, "x-vscode-user-agent-library-version": "electron-fetch", "X-Initiator": "user", + // Harmless no-op on /chat/completions and /responses; required by /v1/messages. + "anthropic-version": ANTHROPIC_API_VERSION, "Accept": stream ? "text/event-stream" : "application/json" }; } - // Sanitize messages for GitHub Copilot /chat/completions endpoint. + // Sanitize messages for GitHub Copilot /chat/completions endpoint (gpt/gemini/grok models — + // claude models never reach this, see execute() below). // The endpoint only accepts 'text' and 'image_url' content part types. // Tool-related content (tool_use, tool_result, thinking) must be serialized as text. sanitizeMessagesForChatCompletions(body) { if (!body?.messages) return body; const sanitized = { ...body }; - - // Handle response_format for Claude models via GitHub - // GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt - // AND prepend a reminder to the last user message for maximum effectiveness - if (body.response_format && body.model?.includes('claude')) { - const responseFormat = body.response_format; - let systemInstruction = ''; - if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) { - systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.'; - } else if (responseFormat.type === 'json_object') { - systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.'; - } - if (systemInstruction) { - // Add to system message - const systemIdx = body.messages.findIndex(m => m.role === 'system'); - if (systemIdx >= 0) { - body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content; - } else { - body.messages.unshift({ role: 'system', content: systemInstruction }); - } - - // Also prepend to the last user message as a reminder - const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop(); - if (lastUserIdx >= 0) { - const userMsg = body.messages[lastUserIdx]; - const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); - userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent; - } - } - } sanitized.messages = body.messages.map(msg => { // assistant messages with only tool_calls have content: null — leave as-is if (!msg.content) return msg; @@ -138,6 +123,15 @@ export class GithubExecutor extends BaseExecutor { async execute(options) { const { model, log } = options; + // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the only + // Copilot endpoint that surfaces prompt-cache token counts for Claude. Detected by + // model NAME (not a registry field): Copilot's live model catalog regularly exposes + // claude-* variants the static registry hasn't caught up with yet (see registry/github.js). + if (this.isClaudeModel(model)) { + log?.debug("GITHUB", `Using /v1/messages route for ${model}`); + return this.executeWithMessagesEndpoint(options); + } + // Only use /responses for models that are explicitly known to need it (e.g. gpt codex models) // and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062). if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) { @@ -145,8 +139,8 @@ export class GithubExecutor extends BaseExecutor { return this.executeWithResponsesEndpoint(options); } - // Sanitize messages before sending to /chat/completions - // This handles Claude models on GitHub Copilot which reject non-text/image_url content types + // Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the + // endpoint rejects non-text/image_url content parts). const sanitizedOptions = { ...options, body: this.sanitizeMessagesForChatCompletions(options.body) @@ -251,6 +245,101 @@ export class GithubExecutor extends BaseExecutor { }; } + // Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github — + // see the note in execute() above), so we translate to Anthropic-native ourselves. + // This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject + // cache_control — /chat/completions never gets there, so it never sees cache tokens. + async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const url = this.config.messagesUrl; + const headers = this.buildHeaders(credentials, stream); + + // Force stream:true upstream regardless of client preference, same as + // executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already + // knows how to buffer an SSE response into a single JSON reply when the client + // asked for stream:false. + const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github"); + // _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js + // normally strips it before dispatch and threads it into the response state to + // restore original tool names; we must do the same here, or Anthropic's strict + // schema rejects the extra field with a 400. + const toolNameMap = transformedBody._toolNameMap; + delete transformedBody._toolNameMap; + + log?.debug("GITHUB", "Sending translated request to /v1/messages"); + + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal + }, proxyOptions); + + if (!response.ok) { + return { response, url, headers, transformedBody }; + } + + const state = initState(FORMATS.CLAUDE); + state.model = model; + if (toolNameMap) state.toolNameMap = toolNameMap; + + const decoder = new TextDecoder(); + let buffer = ""; + + const emitAll = (controller, chunks) => { + for (const c of chunks) { + controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai"))); + } + }; + + const transformStream = new TransformStream({ + async transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parsed = parseSSELine(trimmed); + if (!parsed) continue; + + if (parsed.done && stream === true) { + controller.enqueue(new TextEncoder().encode(SSE_DONE)); + continue; + } + + emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state)); + } + }, + flush(controller) { + if (buffer.trim()) { + const parsed = parseSSELine(buffer.trim()); + if (parsed && !parsed.done) { + emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state)); + } + } + } + }); + + if (!response.body) { + return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody }; + } + const convertedStream = response.body.pipeThrough(transformStream); + + return { + response: new Response(convertedStream, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }), + url, + headers, + transformedBody + }; + } + async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) { try { const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", { diff --git a/open-sse/executors/grok-cli.js b/open-sse/executors/grok-cli.js new file mode 100644 index 00000000..59baa2e2 --- /dev/null +++ b/open-sse/executors/grok-cli.js @@ -0,0 +1,552 @@ +import crypto from "node:crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "../services/oauthCredentialManager.js"; +import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; +import { getModelUpstreamId } from "../config/providerModels.js"; +import { + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_VERSION, + supportsGrokCliReasoningEffort, +} from "../config/grokCli.js"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; +import { getConsistentMachineId } from "../shared/machineId.js"; + +// Server-generated item id prefixes that /responses cannot resolve when store=false +const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; + +// Hosted tool types executed server-side by Grok CLI backend +const HOSTED_TOOL_TYPES = new Set([ + "web_search", + "x_search", + "web_search_preview", + "file_search", + "image_generation", + "code_interpreter", + "mcp", + "local_shell", +]); + +// Fields accepted by cli-chat-proxy Responses API (mirrors Codex allowlist + Grok extras) +const RESPONSES_API_ALLOWLIST = new Set([ + "model", + "input", + "instructions", + "tools", + "tool_choice", + "stream", + "store", + "reasoning", + "include", + "temperature", + "top_p", + "max_output_tokens", + "parallel_tool_calls", + "text", + "metadata", + "prompt_cache_key", +]); + +const EFFORT_LEVELS = ["low", "medium", "high", "xhigh"]; +const GROK_CLI_TURN_STORE_MAX = 5000; +const GROK_CLI_NATIVE_ITEM_ID = /^(?:rs|msg|fc)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const GROK_CLI_FREEFORM_TOOL_PARAMETERS = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], +}; + +// Per-session last turn index so multi-turn headers never go backwards within this process +const sessionTurnStore = new Map(); +let requestTurnStore = new WeakMap(); + +/** + * Count user turns in a Responses `input` array. + * Official CLI sets x-grok-turn-idx to the 1-based conversation turn (≈ user messages). + * HAR: first chat turn → "1". + */ +export function countGrokCliUserTurns(input) { + if (!Array.isArray(input)) return 1; + let n = 0; + for (const item of input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const type = typeof item.type === "string" ? item.type : ""; + // Responses message items (type omitted or "message") with role user + if (item.role === "user" && (!type || type === "message")) n += 1; + } + return Math.max(1, n); +} + +/** + * Resolve monotonic turn index for a session. + * Prefers user-message count from the payload (full history clients), but never + * decreases vs the last index observed for the same sessionId in this process. + */ +export function resolveGrokCliTurnIdx(sessionId, input, requestKey = null) { + const fromInput = countGrokCliUserTurns(input); + if (!sessionId) return fromInput; + + if (requestKey && requestTurnStore.has(requestKey)) { + return requestTurnStore.get(requestKey); + } + + const now = Date.now(); + const existing = sessionTurnStore.get(sessionId); + const prev = existing && now - existing.lastUsed <= MEMORY_CONFIG.sessionTtlMs + ? existing.turn + : 0; + if (existing) sessionTurnStore.delete(sessionId); + + // A new delta-style request advances the turn; retries reuse requestKey. + const turn = prev > 0 ? Math.max(fromInput, prev + (requestKey ? 1 : 0)) : fromInput; + while (sessionTurnStore.size >= GROK_CLI_TURN_STORE_MAX) { + sessionTurnStore.delete(sessionTurnStore.keys().next().value); + } + sessionTurnStore.set(sessionId, { turn, lastUsed: now }); + if (requestKey) requestTurnStore.set(requestKey, turn); + return turn; +} + +/** Test helper — clear in-memory turn counters */ +export function _resetGrokCliTurnStore() { + sessionTurnStore.clear(); + requestTurnStore = new WeakMap(); +} + +export function _getGrokCliTurnStoreSize() { + return sessionTurnStore.size; +} + +export function normalizeGrokCliEffort(value) { + const effort = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (effort === "max") return "xhigh"; + if (EFFORT_LEVELS.includes(effort)) return effort; + return "high"; +} + +export { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; + +export function resolveGrokCliSessionId(credentials, body) { + // ponytail: clients without stable thread metadata share one connection session; + // split further when their wire format exposes a durable conversation id. + const explicitSessionBody = { + prompt_cache_key: body?.prompt_cache_key, + session_id: body?.session_id, + conversation_id: body?.conversation_id, + metadata: body?.metadata, + }; + return resolveSessionId({ + headers: credentials?.rawHeaders, + body: explicitSessionBody, + connectionId: credentials?.connectionId || credentials?.id, + workspaceId: credentials?.providerSpecificData?.workspaceId, + scope: "grok-cli", + }); +} + +function stringifyGrokCliToolOutput(output) { + if (typeof output === "string") return output; + if (output === undefined) return ""; + return JSON.stringify(output); +} + +function isNativeGrokCliItemId(id) { + return typeof id === "string" && GROK_CLI_NATIVE_ITEM_ID.test(id); +} + +function normalizeGrokCliInputItem(item) { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const { internal_chat_message_metadata_passthrough: _metadata, ...clean } = item; + + if (item.type === "reasoning") { + if (!isNativeGrokCliItemId(item.id) || typeof item.encrypted_content !== "string") return null; + return clean; + } + + if (item.type === "custom_tool_call") { + const callId = item.call_id || item.id; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (!callId || !name) return null; + return { + type: "function_call", + call_id: callId, + name, + arguments: JSON.stringify({ input: stringifyGrokCliToolOutput(item.input ?? item.arguments) }), + }; + } + + if (item.type === "custom_tool_call_output" || item.type === "function_call_output") { + const callId = item.call_id || item.id; + if (!callId) return null; + return { + type: "function_call_output", + call_id: callId, + output: stringifyGrokCliToolOutput(item.output), + }; + } + + if (item.type === "function_call") { + const callId = item.call_id || item.id; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (!callId || !name) return null; + return { + type: "function_call", + ...(isNativeGrokCliItemId(item.id) ? { id: item.id } : {}), + call_id: callId, + name, + arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}), + ...(typeof item.status === "string" ? { status: item.status } : {}), + }; + } + + return clean; +} + +export function normalizeGrokCliInput(body) { + if (!Array.isArray(body?.input)) return body; + const normalized = body.input.map(normalizeGrokCliInputItem).filter(Boolean); + const callIds = new Set( + normalized + .filter((item) => item?.type === "function_call" && item.call_id) + .map((item) => item.call_id) + ); + body.input = normalized.filter( + (item) => item?.type !== "function_call_output" || callIds.has(item.call_id) + ); + return body; +} + +function stripStoredItemReferences(body) { + if (!Array.isArray(body.input)) return; + body.input = body.input.filter((item) => { + if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; + if (item && typeof item === "object" && !Array.isArray(item)) { + if (item.type === "item_reference") return false; + if ( + typeof item.id === "string" && + SERVER_ID_PATTERN.test(item.id) && + !isNativeGrokCliItemId(item.id) + ) delete item.id; + } + return true; + }); +} + +/** + * Flatten Chat Completions tool shape → Responses flat format. + * Keep hosted tools (web_search / x_search) passthrough. + */ +function normalizeGrokCliTools(body) { + if (!Array.isArray(body.tools) || body.tools.length === 0) { + delete body.tools; + delete body.tool_choice; + return; + } + const validNames = new Set(); + const hostedTypes = new Set(); + body.tools = body.tools.filter((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const type = typeof tool.type === "string" ? tool.type : ""; + + if (type !== "function") { + // Hosted tools: { type: "web_search" } / { type: "x_search" } + if (HOSTED_TOOL_TYPES.has(type)) { + hostedTypes.add(type); + return true; + } + // Nested function shape without type + if (!type && tool.function) { + // fall through to function flatten below + } else if (!type || typeof tool.name === "string") { + // treat as bare function if name present + } else { + return false; + } + } + + const isFunction = + type === "function" || type === "" || tool.function || typeof tool.name === "string"; + if (!isFunction || HOSTED_TOOL_TYPES.has(type)) { + return HOSTED_TOOL_TYPES.has(type); + } + + const fn = + tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? tool.function + : null; + const rawName = + typeof tool.name === "string" ? tool.name : typeof fn?.name === "string" ? fn.name : ""; + const name = rawName.trim(); + if (!name) return false; + + const description = + typeof tool.description === "string" + ? tool.description + : typeof fn?.description === "string" + ? fn.description + : ""; + const parameters = type === "custom" + ? GROK_CLI_FREEFORM_TOOL_PARAMETERS + : tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? tool.parameters + : fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) + ? fn.parameters + : { type: "object", properties: {} }; + + for (const k of Object.keys(tool)) delete tool[k]; + tool.type = "function"; + tool.name = name.slice(0, 128); + if (description) tool.description = description; + tool.parameters = parameters; + validNames.add(tool.name); + return true; + }); + + if (body.tools.length === 0) { + delete body.tools; + delete body.tool_choice; + return; + } + + if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) { + const choiceType = typeof body.tool_choice.type === "string" ? body.tool_choice.type : ""; + if (choiceType === "function" || choiceType === "custom") { + const rawName = body.tool_choice.name ?? body.tool_choice.function?.name; + const name = typeof rawName === "string" ? rawName.trim().slice(0, 128) : ""; + if (!name || !validNames.has(name)) delete body.tool_choice; + else body.tool_choice = { type: "function", name }; + } else if (!hostedTypes.has(choiceType)) { + delete body.tool_choice; + } + } +} + +function resolveEffortFromModel(modelId) { + if (!modelId || typeof modelId !== "string") return null; + for (const level of EFFORT_LEVELS) { + if (modelId.endsWith(`-${level}`)) return level; + } + return null; +} + +/** + * Grok CLI Executor — OpenAI Responses API on cli-chat-proxy.grok.com + * Auth: OAuth device-code access token (xai-grok-cli). + */ +export class GrokCliExecutor extends BaseExecutor { + constructor() { + super("grok-cli", PROVIDERS["grok-cli"]); + this._currentSessionId = null; + this._currentReqId = null; + this._currentTurnIdx = 1; + this._agentId = null; + } + + buildUrl() { + return this.config.baseUrl; + } + + async refreshCredentials(credentials, log, proxyOptions = null) { + if (!credentials?.refreshToken) return null; + return refreshProviderCredentials("grok-cli", credentials, log, proxyOptions); + } + + needsRefresh(credentials) { + return shouldRefreshCredentials("grok-cli", credentials); + } + + buildHeaders(credentials, stream = true) { + const headers = super.buildHeaders(credentials, stream); + + // Static fingerprint from registry + const staticHeaders = this.config.headers || {}; + for (const [k, v] of Object.entries(staticHeaders)) { + if (v != null && headers[k] === undefined) headers[k] = v; + } + + headers["x-grok-client-identifier"] = + this.config.clientIdentifier || headers["x-grok-client-identifier"] || GROK_CLI_CLIENT_IDENTIFIER; + headers["x-grok-client-version"] = + this.config.clientVersion || headers["x-grok-client-version"] || GROK_CLI_VERSION; + + const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID(); + const reqId = this._currentReqId || crypto.randomUUID(); + headers["x-grok-session-id"] = sessionId; + // CLI uses the same id for conv + session on chat turns + headers["x-grok-conv-id"] = sessionId; + headers["x-grok-req-id"] = reqId; + headers["x-grok-turn-idx"] = String(this._currentTurnIdx || 1); + + if (this._agentId) headers["x-grok-agent-id"] = this._agentId; + + // Surface model override (CLI always sets this) + if (this._currentModel) headers["x-grok-model-override"] = this._currentModel; + + // Identity: mapTokens stores email top-level AND in providerSpecificData; + // fall back either way so OAuth connections always fingerprint like the CLI. + const psd = credentials?.providerSpecificData || {}; + const email = psd.email || credentials?.email; + const userId = psd.userId || credentials?.userId || credentials?.providerUserId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + + return headers; + } + + parseError(response, bodyText) { + // 402 personal-team-blocked:spending-limit → surface as payment/quota for fallback + if (response.status === 402 && bodyText) { + try { + const json = JSON.parse(bodyText); + const code = json?.code || ""; + const msg = json?.error || json?.message || bodyText; + return { + status: 402, + message: typeof msg === "string" ? msg : bodyText, + code: typeof code === "string" ? code : undefined, + }; + } catch { + /* fall through */ + } + } + return super.parseError(response, bodyText); + } + + transformRequest(model, body, stream, credentials) { + // Session / request ids for headers — stable per client conversation when possible + const requestKey = body; + this._currentSessionId = resolveGrokCliSessionId(credentials, body); + this._currentReqId = crypto.randomUUID(); + this._agentId = + credentials?.providerSpecificData?.deviceId || + credentials?.providerSpecificData?.agentId || + null; + + // Normalize Responses input + const normalized = normalizeResponsesInput(body.input); + if (normalized) body.input = normalized; + + // Chat Completions clients arrive with messages[] — translator should have + // converted already, but guard empty input. + if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { + if (Array.isArray(body.messages) && body.messages.length > 0) { + // Soft fallback: map messages → input messages (string content only) + body.input = body.messages.map((m) => ({ + type: "message", + role: m.role || "user", + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + })); + delete body.messages; + } else { + body.input = [{ type: "message", role: "user", content: "..." }]; + } + } + + // Keep role:"system" as-is — official grok-pager HAR sends system, not developer + // (Codex converts system→developer; Grok CLI does not). + normalizeGrokCliInput(body); + stripStoredItemReferences(body); + normalizeGrokCliTools(body); + + // Turn index after input is finalized (user-message count, monotonic per session) + this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input, requestKey); + + body.stream = true; + body.store = false; + + // Resolve upstream model id (strip effort suffix virtual models) + let modelEffort = resolveEffortFromModel(body.model || model); + let resolvedModel = body.model || model; + if (modelEffort) { + resolvedModel = resolvedModel.replace(new RegExp(`-${modelEffort}$`), ""); + } + resolvedModel = getModelUpstreamId("gcli", resolvedModel) || resolvedModel; + // Also try provider id key + if (resolvedModel === (body.model || model)) { + resolvedModel = getModelUpstreamId("grok-cli", resolvedModel) || resolvedModel; + } + body.model = resolvedModel; + this._currentModel = resolvedModel; + + // Reasoning effort priority: explicit > reasoning_effort > model suffix > default high. + // grok-build and Composer reject reasoningEffort but still accept summary/encrypted continuity. + const supportsReasoningEffort = supportsGrokCliReasoningEffort(resolvedModel); + if (!body.reasoning || typeof body.reasoning !== "object") { + body.reasoning = { summary: "concise" }; + if (supportsReasoningEffort) { + body.reasoning.effort = normalizeGrokCliEffort(body.reasoning_effort || modelEffort); + } + } else { + if (supportsReasoningEffort) { + body.reasoning.effort = normalizeGrokCliEffort( + body.reasoning.effort || body.reasoning_effort || modelEffort, + ); + } else { + delete body.reasoning.effort; + } + if (!body.reasoning.summary) body.reasoning.summary = "concise"; + } + delete body.reasoning_effort; + + // Encrypted reasoning for multi-turn continuity (CLI always requests this) + if (body.reasoning && body.reasoning.effort !== "none") { + const include = Array.isArray(body.include) ? body.include : []; + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + body.include = include; + } + + // Drop Chat Completions leftovers that Responses rejects + delete body.messages; + delete body.max_tokens; + delete body.max_completion_tokens; + delete body.n; + delete body.seed; + delete body.logprobs; + delete body.top_logprobs; + delete body.frequency_penalty; + delete body.presence_penalty; + delete body.logit_bias; + delete body.user; + delete body.stream_options; + delete body.prompt_cache_retention; + delete body.safety_identifier; + delete body.previous_response_id; // store=false → cannot resolve + + for (const k of Object.keys(body)) { + if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k]; + } + + return body; + } + + async execute(args) { + // Lazy-resolve stable agent id once per process if connection has none + if (!this._agentId && !args.credentials?.providerSpecificData?.deviceId) { + try { + const mid = await getConsistentMachineId("grok-cli-agent"); + // Format as UUID-ish for header aesthetics + this._agentId = [ + mid.slice(0, 8), + mid.slice(8, 12), + "5" + mid.slice(13, 16), + "a" + mid.slice(17, 20), + mid.slice(0, 12).padEnd(12, "0"), + ].join("-"); + } catch { + this._agentId = crypto.randomUUID(); + } + } else if (args.credentials?.providerSpecificData?.deviceId) { + this._agentId = args.credentials.providerSpecificData.deviceId; + } + + return super.execute(args); + } +} + +export default GrokCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 52ae29bb..bd96ab49 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -9,17 +9,21 @@ import { KimchiExecutor } from "./kimchi.js"; import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; -import { QwenExecutor } from "./qwen.js"; import { OpenCodeExecutor } from "./opencode.js"; -import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; +import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; import { OllamaLocalExecutor } from "./ollama-local.js"; import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; +import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +import TraeExecutor from "./trae.js"; +import ZedExecutor from "./zed.js"; +import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; +import { DevinCliExecutor } from "./devin-cli.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -35,10 +39,11 @@ const executors = { cu: new CursorExecutor(), // Alias for cursor vertex: new VertexExecutor("vertex"), "vertex-partner": new VertexExecutor("vertex-partner"), - qwen: new QwenExecutor(), opencode: new OpenCodeExecutor(), - "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), + "grok-cli": new GrokCliExecutor(), + gcli: new GrokCliExecutor(), // Alias + gb: new GrokCliExecutor(), // Alias (Grok Build) "perplexity-web": new PerplexityWebExecutor(), "ollama-local": new OllamaLocalExecutor(), commandcode: new CommandCodeExecutor(), @@ -46,6 +51,11 @@ const executors = { "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), + "codebuddy-intl": new CodeBuddyIntlExecutor(), + trae: new TraeExecutor(), + zed: new ZedExecutor(), + windsurf: new WindsurfExecutor(), + "devin-cli": new DevinCliExecutor(), }; const defaultCache = new Map(); @@ -73,13 +83,17 @@ export { CodexExecutor } from "./codex.js"; export { CursorExecutor } from "./cursor.js"; export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; -export { QwenExecutor } from "./qwen.js"; export { OpenCodeExecutor } from "./opencode.js"; -export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; +export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; +export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +export { default as TraeExecutor } from "./trae.js"; +export { default as ZedExecutor } from "./zed.js"; +export { default as WindsurfExecutor } from "./windsurf.js"; +export { DevinCliExecutor } from "./devin-cli.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 556e0c72..77616618 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -1,10 +1,221 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { resolveKiroModel } from "../config/kiroConstants.js"; +import { + KIRO_CODEWHISPERER_TARGET, + KIRO_ENDPOINT_FALLBACK_STATUSES, + resolveKiroModel, +} from "../config/kiroConstants.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; +import { STREAM_FIRST_CHUNK_TIMEOUT_MS } from "../config/runtimeConfig.js"; + +const KIRO_REPAIR_BUFFER_MAX_BYTES = 8 * 1024 * 1024; +const KIRO_REPAIR_HEARTBEAT_MS = 10_000; +const KIRO_SHORT_FINAL_MAX_CHARS = 800; +const EVENTSTREAM_MAX_MESSAGE_BYTES = 24 * 1024 * 1024; +const EVENTSTREAM_MAX_HEADERS_BYTES = 128 * 1024; +const KIRO_EVENT_TYPES = new Set([ + "assistantResponseEvent", + "reasoningContentEvent", + "codeEvent", + "toolUseEvent", + "messageStopEvent", + "metadataEvent", + "MetadataEvent", + "contextUsageEvent", + "meteringEvent", + "metricsEvent" +]); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + } + return value >>> 0; +}); + +const REPAIR_INSTRUCTIONS = Object.freeze({ + tool: "Retry the previous response because its Kiro tool_call wrapper was malformed. If you use the wrapper tool named tool_call, its input must contain a non-empty name and an arguments field.", + ellipsis: "Retry the previous response because it ended with only an ellipsis. Return the complete final answer, not only ... or ….", + short_final: "Retry the previous response because its final only announced a future action. Complete the check now and return the result or a concrete blocker." +}); +const SHORT_FUTURE_ACTION = /^(?:(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再)(?:補|查|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|我(?:會|要|將)(?:再|重新)?(?:補(?:齊|查)?|抓取|查(?:詢)?|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b)/iu; +// Keep this tied to the observed whole-response signature. Broader Chinese +// result/progress heuristics create false positives for completed findings. +const OBSERVED_TRAILING_FUTURE_ACTION = /^目前證據顯示[\s\S]{1,700}[。.!?;;]\s*最後補查\s+504\s+access\s+log[,,]\s*確認\s+host[//]路徑與是否為集中流量[。.!]?$/iu; +const ENGLISH_FUTURE_ACTION = /^(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b/iu; +const ENGLISH_RESULT_CLAUSE = /(?:[:;\n]|[.!?]\s+\S|\b(?:status|checksum|response|deployment)\s+(?:is|are|was|were|matches?|equals?|returned)\b)/iu; +const CHINESE_FUTURE_ACTION = /^(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再|我(?:會|要|將)(?:再|重新)?)(?:補|抓取|查|確認|驗證|追|繼續|檢查|測試)/u; +const CHINESE_RESULT_CLAUSE = /(?:[。!?]\s*\S|(?:版本|狀態|回應|結果|部署|校驗碼)(?:是|為|等於|顯示))/u; +const USER_WAIT = /(?:請(?:你|先)|你(?:先|需要|可以|提供|確認|批准|允許)|等待(?:你|使用者)|等你|核准|同意|授權|\b(?:after|when|once)\s+you\b|\byour\s+(?:approval|confirmation|permission|input)\b|\bwait(?:ing)?\s+for\s+you\b|\bplease\s+(?:approve|confirm|provide|send)\b)/iu; +const COMPLETED_FINAL = /(?:已(?:經)?完成|完成(?:了|驗證|確認)|修復完成|確認無誤|驗證(?:完成|通過)|測試(?:均)?通過|結論|總結|\b(?:done|completed|fixed|verified|confirmed|passed|in conclusion|summary)\b|\b(?:is|are) complete\b)/iu; +const RESULT_EVIDENCE = /(?:顯示|發現|因此|成功|失敗|正常|無錯誤|沒有錯誤|\b(?:found|shows?|showed|because|therefore|succeeded|failed|healthy|green|no errors?)\b)/iu; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function envPositiveInt(name, fallback) { + const parsed = Number.parseInt(process.env?.[name] || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function concatChunks(chunks, totalBytes) { + const output = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function makeAbortError(reason) { + const error = new Error(reason?.message || reason || "Request aborted"); + error.name = "AbortError"; + return error; +} + +async function readWithTimeout(reader, signal, timeoutMs, message) { + if (signal?.aborted) throw makeAbortError(signal.reason); + let timeout; + let abortHandler; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + const abortPromise = new Promise((_, reject) => { + abortHandler = () => reject(makeAbortError(signal.reason)); + signal?.addEventListener("abort", abortHandler, { once: true }); + }); + try { + return await Promise.race([reader.read(), timeoutPromise, abortPromise]); + } finally { + clearTimeout(timeout); + signal?.removeEventListener?.("abort", abortHandler); + } +} + +async function readResponsePrefix(response, signal, maxBytes, timeoutMs) { + const reader = response?.body?.getReader?.(); + if (!reader) return ""; + const chunks = []; + let totalBytes = 0; + try { + while (totalBytes < maxBytes) { + const { done, value } = await readWithTimeout( + reader, + signal, + timeoutMs, + "Kiro retry error body stalled" + ); + if (done) break; + const remaining = maxBytes - totalBytes; + const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value; + chunks.push(chunk); + totalBytes += chunk.byteLength; + if (value.byteLength > remaining) break; + } + } finally { + await reader.cancel("bounded Kiro retry error body").catch(() => {}); + } + return decoder.decode(concatChunks(chunks, totalBytes)); +} + +function appendRepairInstruction(body, kind) { + const repaired = structuredClone(body || {}); + const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response."; + repaired.systemPrompt = repaired.systemPrompt + ? `${repaired.systemPrompt}\n\n${instruction}` + : instruction; + return repaired; +} + +function normalizeStopReason(value) { + const reason = String(value || "").trim().replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); + if (["endturn", "end_turn", "stop", "stop_sequence"].includes(reason)) return "end_turn"; + if (["tooluse", "tool_use", "tool_calls"].includes(reason)) return "tool_use"; + if (["maxtokens", "max_tokens", "max_output_tokens", "length"].includes(reason)) return "max_tokens"; + return reason || null; +} + +// Of the reasons stopDisposition() folds into "terminal_incomplete", only these +// mean "usable as far as it got, then the budget ran out" -- the case +// finish_reason "length" exists for. cancelled / pause_turn are abandoned turns +// whose partial content must stay private, so they are deliberately absent. +const KIRO_TRUNCATION_STOP_REASONS = new Set(["model_context_window_exceeded", "max_tokens"]); + +function stopDisposition(stopReason, hasToolCalls) { + if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure"; + if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete"; + if (stopReason === "refusal" || /(?:content.*filter|guardrail|safety|policy|blocked)/u.test(stopReason)) return "terminal_refusal"; + if (stopReason === "max_tokens") return hasToolCalls ? "terminal_incomplete" : "length"; + if (stopReason && !["end_turn", "tool_use"].includes(stopReason)) return "unknown_failure"; + if (hasToolCalls || stopReason === "tool_use") return "tool_use"; + if (!stopReason || stopReason === "end_turn") return "complete"; + return "unknown_failure"; +} + +function mergeStopReason(current, incoming) { + if (!incoming) return current; + if (!current) return incoming; + const severity = (reason) => { + const disposition = stopDisposition(reason, false); + if (disposition === "terminal_refusal") return 6; + if (disposition === "terminal_incomplete") return 5; + if (disposition === "unknown_failure") return 4; + if (disposition === "retryable_protocol_failure") return 3; + if (disposition === "length") return 2; + return 1; + }; + return severity(incoming) > severity(current) ? incoming : current; +} + +function isEllipsisOnly(value) { + return ["...", "…"].includes(String(value || "").trim()); +} + +function isShortFutureAction(value) { + const text = String(value || "").trim().replaceAll("’", "'"); + if (OBSERVED_TRAILING_FUTURE_ACTION.test(text)) return true; + if (ENGLISH_FUTURE_ACTION.test(text) && ENGLISH_RESULT_CLAUSE.test(text)) return false; + if (CHINESE_FUTURE_ACTION.test(text) && CHINESE_RESULT_CLAUSE.test(text)) return false; + return text.length > 0 && text.length <= KIRO_SHORT_FINAL_MAX_CHARS && + SHORT_FUTURE_ACTION.test(text) && !USER_WAIT.test(text) && + !COMPLETED_FINAL.test(text) && !RESULT_EVIDENCE.test(text); +} + +function encodeSSEError(code, message, details) { + return encoder.encode(`data: ${JSON.stringify({ error: { + message, + type: "upstream_error", + code, + ...(details ? { details } : {}) + } })}\n\ndata: [DONE]\n\n`); +} + +function inspectSSEChunk(chunk, state) { + for (const line of decoder.decode(chunk).split("\n")) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + if (event.error) state.error = event.error; + for (const choice of event.choices || []) { + const delta = choice.delta || {}; + if (typeof delta.content === "string") state.content += delta.content; + if (typeof delta.reasoning_content === "string") state.reasoning += delta.reasoning_content; + if (delta.tool_calls?.length) state.hasToolCalls = true; + } + } catch { /* a malformed SSE line is diagnosed by the transformer */ } + } +} /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -15,12 +226,17 @@ export class KiroExecutor extends BaseExecutor { super("kiro", PROVIDERS.kiro); } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const headers = { ...this.config.headers, "Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Invocation-Id": uuidv4() }; + if (url.includes("://codewhisperer.")) { + headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET; + } else { + delete headers["X-Amz-Target"]; + } // API-key auth: the key is stored as accessToken and sent as a bearer token // exactly like an OAuth access token, but with an extra `tokentype: API_KEY` @@ -35,8 +251,8 @@ export class KiroExecutor extends BaseExecutor { const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); if (isApiKey && apiKey) { headers["Authorization"] = `Bearer ${apiKey}`; - headers["tokentype"] = "API_KEY"; - } else if (credentials.accessToken) { + headers["TokenType"] = "API_KEY"; + } else if (credentials?.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; if (isExternalIdp) { headers["TokenType"] = "EXTERNAL_IDP"; @@ -49,14 +265,14 @@ export class KiroExecutor extends BaseExecutor { /** * Auth-aware endpoint ordering. * - * API-key Kiro connections store a raw CodeWhisperer credential (validated - * against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles). + * API-key Kiro connections use the Amazon Q surface. The legacy + * codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key + * but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400 + * is terminal in BaseExecutor, putting CodeWhisperer first prevents the working + * q.* endpoint from ever being tried. Keep q.* first only for api_key accounts. + * * The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens - * and rejects an `tokentype: API_KEY` token with 401/403 — which - * BaseExecutor.execute() returns immediately (only 429 / network errors fall - * through to the next host). So for api-key auth we must try the *.amazonaws.com - * CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never - * routes api-key traffic through kiro.dev. External IdP enterprise tokens also + * and rejects TokenType=API_KEY. External IdP enterprise tokens instead * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. * Other OAuth methods keep the default order (kiro.dev first) since their * tokens are what that gateway accepts. @@ -81,6 +297,14 @@ export class KiroExecutor extends BaseExecutor { const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); + if (authMethod === "api_key") { + const q = amazon.filter((u) => u.includes("://q.")); + const remaining = amazon.filter((u) => !u.includes("://q.")); + return q.length > 0 + ? [...q, ...remaining, ...others] + : [...amazon, ...others]; + } + return amazon.length > 0 ? [...amazon, ...others] : baseUrls; } @@ -89,6 +313,14 @@ export class KiroExecutor extends BaseExecutor { return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } + // Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be + // terminal: sending the same malformed body to every surface cannot repair it. + shouldRetry(status, urlIndex) { + const hasFallback = urlIndex + 1 < this.getFallbackCount(); + return super.shouldRetry(status, urlIndex) + || (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status)); + } + transformRequest(model, body, stream, credentials) { return body; } @@ -111,393 +343,826 @@ export class KiroExecutor extends BaseExecutor { */ async execute(args) { const result = await super.execute(args); - if (result?.response?.ok) { - result.response = this.transformEventStreamToSSE(result.response, args.model); - } + if (result?.response?.ok) this.attachIntegrityGate(result, args); return result; } - /** - * Transform AWS EventStream binary response to SSE text stream - * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout - */ - transformEventStreamToSSE(response, model) { - let buffer = new Uint8Array(0); - let chunkIndex = 0; + attachIntegrityGate(result, args) { + const abortController = new AbortController(); + const maxBytes = envPositiveInt("KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES", KIRO_REPAIR_BUFFER_MAX_BYTES); + const legacyTimeout = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TIMEOUT_MS", STREAM_FIRST_CHUNK_TIMEOUT_MS); + const ttftTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS", legacyTimeout); + const stallTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS", legacyTimeout); + const repairEnabled = args.credentials?.providerSpecificData?.kiroToolCallRepair !== false && + process.env.KIRO_TOOL_CALL_REPAIR !== "false"; + const forwardAbort = () => abortController.abort(args.signal?.reason); + args.signal?.addEventListener("abort", forwardAbort, { once: true }); + let open = true; + let heartbeatTimer; + + const stream = new ReadableStream({ + start: async (controller) => { + const heartbeat = () => { + if (!open) return; + try { + controller.enqueue(encoder.encode(": kiro-validation\n\n")); + } catch { + open = false; + } + }; + heartbeat(); + heartbeatTimer = setInterval(heartbeat, KIRO_REPAIR_HEARTBEAT_MS); + + try { + const bytes = await this.runIntegrityRecovery(result.response, args, { + signal: abortController.signal, + maxBytes, + ttftTimeoutMs, + stallTimeoutMs, + repairEnabled + }); + if (abortController.signal.aborted) throw makeAbortError(abortController.signal.reason); + controller.enqueue(bytes); + controller.close(); + } catch (error) { + if (open && error.name === "AbortError") { + controller.error(error); + } else if (open && error.name !== "AbortError") { + controller.enqueue(encodeSSEError( + "kiro_integrity_gate_failed", + error.message || "Kiro integrity validation failed" + )); + controller.close(); + } + } finally { + open = false; + clearInterval(heartbeatTimer); + args.signal?.removeEventListener?.("abort", forwardAbort); + } + }, + cancel(reason) { + open = false; + clearInterval(heartbeatTimer); + abortController.abort(reason || "client cancelled"); + } + }); + + result.response = new Response(stream, { + status: result.response.status, + statusText: result.response.statusText, + headers: { ...SSE_HEADERS } + }); + } + + async runIntegrityRecovery(rawResponse, args, options) { + const first = await this.readRecoverableIntegrityAttempt( + rawResponse, + args.model, + options, + "initial" + ); + if (first.kind === "complete") return first.bytes; + if (first.kind === "terminal_stop" || first.kind === "upstream_error") { + return this.integrityFailureSSE(first); + } + if (first.kind === "invalid_tool" && !options.repairEnabled) { + return encodeSSEError("invalid_kiro_tool_call", first.message, first.diagnostics); + } + + const repairKind = ["ellipsis", "short_final", "invalid_tool"].includes(first.kind) + ? first.kind + : null; + const repairBody = repairKind + ? appendRepairInstruction(args.body, repairKind === "invalid_tool" ? "tool" : repairKind) + : structuredClone(args.body || {}); + + const retry = await BaseExecutor.prototype.execute.call(this, { + ...args, + body: repairBody, + signal: options.signal + }); + if (!retry?.response?.ok) { + let body = ""; + try { + body = await readResponsePrefix( + retry?.response, + options.signal, + Math.min(options.maxBytes, 4096), + options.stallTimeoutMs + ); + } catch (error) { + if (error.name === "AbortError") throw error; + } + return encodeSSEError( + "kiro_integrity_retry_upstream_error", + body || `Kiro integrity retry failed with HTTP ${retry?.response?.status || 502}`, + { status: retry?.response?.status || 502 } + ); + } + + const second = await this.readRecoverableIntegrityAttempt( + retry.response, + args.model, + options, + "retry" + ); + if (second.kind === "complete") return second.bytes; + if (second.kind === "terminal_stop" || second.kind === "upstream_error") { + return this.integrityFailureSSE(second); + } + const code = second.kind === "ellipsis" + ? "kiro_ellipsis_retry_failed" + : second.kind === "short_final" + ? "kiro_short_final_retry_failed" + : second.kind === "invalid_tool" + ? "kiro_tool_call_repair_retry_failed" + : "kiro_missing_terminal_retry_failed"; + return encodeSSEError( + code, + `Kiro integrity validation failed after one bounded retry: ${second.message || second.kind}`, + { attempts: [first.diagnostics, second.diagnostics].filter(Boolean) } + ); + } + + integrityFailureSSE(attempt) { + const disposition = attempt.diagnostics?.stop_disposition; + const code = attempt.diagnostics?.terminal_provenance === "integrity_buffer_exceeded" + ? "kiro_integrity_buffer_exceeded" + : attempt.kind === "upstream_error" + ? "kiro_upstream_eventstream_error" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + return encodeSSEError(code, attempt.message || "Kiro stream ended with a terminal failure", attempt.diagnostics); + } + + async readRecoverableIntegrityAttempt(rawResponse, model, options, attempt) { + try { + return await this.readIntegrityAttempt(rawResponse, model, options, attempt); + } catch (error) { + if (error.name === "AbortError") throw error; + return { + kind: "missing_terminal", + message: error.message || "Kiro transport read failed", + diagnostics: { + attempt, + terminal_provenance: "transport_read_error", + transport_state: "upstream_error", + stop_reason: null, + stop_disposition: "terminal_incomplete", + response_state: "no_semantic_output", + event_counts: {}, + incomplete_frame_bytes: 0 + } + }; + } + } + + async readIntegrityAttempt(rawResponse, model, options, attempt) { + let diagnostics; + const transformed = this.transformEventStreamToSSE(rawResponse, model, { + maxToolBytes: Math.max(1, Math.floor(options.maxBytes / 2)), + onTerminalState: (value) => { + diagnostics = value; + } + }); + const reader = transformed.body.getReader(); + const chunks = []; + let totalBytes = 0; + let sawChunk = false; + const output = { content: "", reasoning: "", hasToolCalls: false, error: null }; + + try { + while (true) { + const timeoutMs = sawChunk ? options.stallTimeoutMs : options.ttftTimeoutMs; + const phase = sawChunk ? "stalled" : "timed out before first chunk"; + const { done, value } = await readWithTimeout( + reader, + options.signal, + timeoutMs, + `Kiro integrity validation ${phase}` + ); + if (done) break; + sawChunk = true; + totalBytes += value.byteLength; + if (totalBytes > options.maxBytes) { + await reader.cancel("kiro_integrity_buffer_exceeded").catch(() => {}); + return { + kind: "terminal_stop", + message: `Kiro integrity buffer exceeded ${options.maxBytes} bytes`, + diagnostics: { terminal_provenance: "integrity_buffer_exceeded" } + }; + } + chunks.push(value); + inspectSSEChunk(value, output); + } + } catch (error) { + await reader.cancel(error.message).catch(() => {}); + throw error; + } + + const safeDiagnostics = { + attempt, + terminal_provenance: diagnostics?.terminal_provenance || "missing_terminal_diagnostics", + transport_state: diagnostics?.transport_state || "unknown", + stop_reason: diagnostics?.stop_reason || null, + stop_disposition: diagnostics?.stop_disposition || "terminal_incomplete", + response_state: diagnostics?.response_state || "no_semantic_output", + event_counts: diagnostics?.event_counts || {}, + incomplete_frame_bytes: diagnostics?.incomplete_frame_bytes || 0 + }; + if (safeDiagnostics.stop_disposition === "retryable_protocol_failure") { + const kind = safeDiagnostics.terminal_provenance === "invalid_tool_call" + ? "invalid_tool" + : "retryable_stop"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (safeDiagnostics.stop_disposition === "terminal_incomplete" || + safeDiagnostics.stop_disposition === "terminal_refusal" || + safeDiagnostics.stop_disposition === "unknown_failure") { + const kind = safeDiagnostics.terminal_provenance === "upstream_eventstream_error" + ? "upstream_error" + : safeDiagnostics.terminal_provenance === "integrity_buffer_exceeded" + ? "terminal_stop" + : ["metadata_stop_reason", "message_stop_event"].includes(safeDiagnostics.terminal_provenance) + ? "terminal_stop" + : "missing_terminal"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (output.error) { + return { kind: "missing_terminal", message: output.error.message, diagnostics: safeDiagnostics }; + } + if (!output.hasToolCalls) { + if (isEllipsisOnly(output.content) || + (!output.content.trim() && isEllipsisOnly(output.reasoning))) { + return { kind: "ellipsis", diagnostics: safeDiagnostics }; + } + if (isShortFutureAction(output.content)) { + return { kind: "short_final", diagnostics: safeDiagnostics }; + } + } + return { kind: "complete", bytes: concatChunks(chunks, totalBytes), diagnostics: safeDiagnostics }; + } + + transformEventStreamToSSE(response, model, options = {}) { const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); const capabilityModel = resolveKiroModel(model).upstream; const contextWindow = getCapabilitiesForModel("kiro", capabilityModel).contextWindow || 200000; + const eventCounts = {}; const state = { - endDetected: false, - finishEmitted: false, + buffer: new Uint8Array(0), + chunkIndex: 0, + toolCounter: 0, + tools: new Map(), + bufferedToolBytes: 0, + hasText: false, + hasReasoning: false, + hasCode: false, hasToolCalls: false, - hasReasoningContent: false, - reasoningChunkCount: 0, - toolCallIndex: 0, - seenToolIds: new Map(), - inThinking: false + sawToolUse: false, + explicitStop: false, + stopReason: null, + terminalProvenance: null, + transportState: "consuming_response", + totalContentLength: 0, + contextUsagePercentage: 0, + hasContextUsage: false, + hasMetering: false, + usage: null, + inThinking: false, + toolValidationError: null, + validatedFrames: 0, + finished: false }; - const transformStream = new TransformStream({ - async transform(chunk, controller) { - // Track output so we can emit a keepalive if this frame yields no chunk. - const enqueueCountBefore = chunkIndex; - // Append to buffer - const newBuffer = new Uint8Array(buffer.length + chunk.length); - newBuffer.set(buffer); - newBuffer.set(chunk, buffer.length); - buffer = newBuffer; + const diagnostics = (overrides = {}) => ({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + stop_reason: state.stopReason, + stop_disposition: stopDisposition(state.stopReason, state.hasToolCalls), + response_state: state.hasToolCalls + ? "valid_tool" + : state.hasText || state.hasReasoning || state.hasCode + ? "text_reasoning" + : state.explicitStop + ? "explicit_stop" + : "no_semantic_output", + event_counts: { ...eventCounts }, + incomplete_frame_bytes: state.buffer.byteLength, + ...overrides + }); + const sseChunk = (delta, finishReason = null, usage) => encoder.encode(`data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}) + })}\n\n`); + const emitDelta = (controller, delta) => { + if (state.chunkIndex === 0) delta = { role: "assistant", ...delta }; + state.chunkIndex++; + controller.enqueue(sseChunk(delta)); + }; + const fail = (controller, provenance, code, message, extra = {}) => { + state.finished = true; + state.terminalProvenance = provenance; + state.transportState = extra.transport_state || "corrupt_frame"; + const detail = diagnostics({ + stop_disposition: extra.stop_disposition || "terminal_incomplete", + ...extra + }); + options.onTerminalState?.(detail); + controller.enqueue(encodeSSEError(code, message, detail)); + }; + const assertToolBufferBound = () => { + if (state.bufferedToolBytes <= (options.maxToolBytes || KIRO_REPAIR_BUFFER_MAX_BYTES / 2)) return; + const error = new Error("Kiro buffered tool input exceeded the integrity memory bound"); + error.code = "KIRO_BUFFER_EXCEEDED"; + throw error; + }; + const appendToolInput = (tool, input) => { + if (input === undefined) return; + if (typeof input === "string") { + if (tool.inputKind && tool.inputKind !== "string") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "string"; + tool.inputChunks ||= []; + tool.inputChunks.push(input); + state.bufferedToolBytes += encoder.encode(input).byteLength; + } else if (input && typeof input === "object" && !Array.isArray(input)) { + if (tool.inputKind && tool.inputKind !== "object") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "object"; + state.bufferedToolBytes -= tool.inputBytes || 0; + tool.inputObject = input; + tool.inputBytes = encoder.encode(JSON.stringify(input)).byteLength; + state.bufferedToolBytes += tool.inputBytes; + } else { + throw new Error("Kiro tool input must be a JSON object"); + } + assertToolBufferBound(); + }; + const parsedToolInput = (tool) => { + if (!tool.inputKind) throw new Error("Kiro tool call is missing input"); + if (tool.inputKind === "object") return tool.inputObject; + try { + const input = JSON.parse(tool.inputChunks.join("")); + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("not an object"); + return input; + } catch (error) { + throw new Error(`Kiro tool input must be valid object JSON (${error.message})`); + } + }; + const emitTools = (controller) => { + for (const tool of state.tools.values()) { + // Validate per tool, not per turn: one unusable fragment used to throw out + // of emitTools and take every other complete tool call in the same turn + // with it, which the client saw as a turn that answered nothing. + let input; + try { + input = parsedToolInput(tool); + if (tool.name === "tool_call") { + if (typeof input.name !== "string" || !input.name.trim()) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name"); + } + if (!Object.prototype.hasOwnProperty.call(input, "arguments")) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments"); + } + } + } catch (error) { + state.droppedTools = (state.droppedTools || 0) + 1; + state.toolValidationError ||= error.message; + console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`); + continue; + } + const index = state.toolCounter++; + emitDelta(controller, { + tool_calls: [{ + index, + id: tool.id, + type: "function", + function: { name: tool.name, arguments: "" } + }] + }); + const serializedInput = JSON.stringify(input); + emitDelta(controller, { + tool_calls: [{ index, function: { arguments: serializedInput } }] + }); + // Tool arguments are billed output like any other completion bytes. They + // were never added to totalContentLength, so the /4 estimator in finish() + // reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose + // entire answer was a tool call. + state.totalContentLength += tool.name.length + serializedInput.length; + state.hasToolCalls = true; + } + state.tools.clear(); + state.bufferedToolBytes = 0; + // A declared tool turn that emitted no usable call is only fatal when the + // turn produced nothing else. Throwing unconditionally here escaped + // emitTools() with provenance "invalid_tool_call", which the integrity gate + // re-derived into a repair retry -- discarding text the client had already + // been promised. + if (state.stopReason === "tool_use" && !state.hasToolCalls && + !state.hasText && !state.hasReasoning && !state.hasCode) { + throw new Error("Kiro tool_use stop reason did not include a complete tool call"); + } + }; + const processEvent = (event, controller) => { + const messageType = event.headers[":message-type"]; + if (messageType === "error" || messageType === "exception") { + fail( + controller, + "upstream_eventstream_error", + "kiro_upstream_eventstream_error", + event.payload?.message || `Kiro upstream sent an EventStream ${messageType}`, + { transport_state: "upstream_error" } + ); + return false; + } - // Parse events from buffer - let iterations = 0; - const maxIterations = 1000; - while (buffer.length >= 16 && iterations < maxIterations) { - iterations++; - const view = new DataView(buffer.buffer, buffer.byteOffset); - const totalLength = view.getUint32(0, false); - - if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break; - - const eventData = buffer.slice(0, totalLength); - buffer = buffer.slice(totalLength); - - const event = parseEventFrame(eventData); - if (!event) continue; - - const eventType = event.headers[":event-type"] || ""; - - // Track total content length for token estimation - if (!state.totalContentLength) state.totalContentLength = 0; - if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; - - // Handle assistantResponseEvent - if (eventType === "assistantResponseEvent" && event.payload?.content) { - let content = event.payload.content; - - // Kiro Claude models can leak blocks into the content stream. - // We strip these literal tags to prevent duplication, as the reasoning - // is already routed correctly via reasoningContentEvent. - if (state.inThinking) { - if (content.includes("")) { - state.inThinking = false; - const after = content.split("").slice(1).join(""); - content = after.startsWith("\n") ? after.substring(1) : after; - } else { - content = ""; // Drop entirely while inside thinking block - } - } else if (content.includes("")) { + const eventType = event.headers[":event-type"] || ""; + const eventCountKey = KIRO_EVENT_TYPES.has(eventType) ? eventType : "other"; + eventCounts[eventCountKey] = (eventCounts[eventCountKey] || 0) + 1; + if (eventType === "assistantResponseEvent" && typeof event.payload?.content === "string") { + let content = event.payload.content; + if (state.inThinking) { + const end = content.indexOf(""); + if (end < 0) content = ""; + else { + state.inThinking = false; + content = content.slice(end + 11).replace(/^\n/u, ""); + } + } else { + const start = content.indexOf(""); + if (start >= 0) { + const end = content.indexOf("", start + 10); + if (end < 0) { state.inThinking = true; - if (content.includes("")) { - state.inThinking = false; - const before = content.split("")[0]; - const after = content.split("").slice(1).join(""); - content = before + (after.startsWith("\n") ? after.substring(1) : after); - } else { - content = content.split("")[0]; - } + content = content.slice(0, start); + } else { + content = content.slice(0, start) + content.slice(end + 11).replace(/^\n/u, ""); } - - if (!content && state.hasReasoningContent) { - // If we stripped everything, skip emitting an empty content chunk - continue; - } - - state.totalContentLength += content.length; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: chunkIndex === 0 - ? { role: "assistant", content } - : { content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle reasoningContentEvent (Kiro thinking / reasoning) - // Kiro returns reasoning as a separate event when the request system - // prompt contains enabled. Surface it - // as OpenAI delta.reasoning_content so downstream translators can map - // it back to Claude thinking blocks / Anthropic reasoning, etc. - if (eventType === "reasoningContentEvent") { - const reasoning = event.payload?.reasoningContentEvent || event.payload || {}; - const reasoningText = (typeof reasoning === "string") - ? reasoning - : (reasoning.text || reasoning.content || ""); - if (reasoningText) { - state.hasReasoningContent = true; - state.totalContentLength += reasoningText.length; - - const reasoningDelta = state.reasoningChunkCount === 0 && chunkIndex === 0 - ? { role: "assistant", reasoning_content: reasoningText } - : { reasoning_content: reasoningText }; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: reasoningDelta, - finish_reason: null - }] - }; - chunkIndex++; - state.reasoningChunkCount++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - } - - // Handle codeEvent - if (eventType === "codeEvent" && event.payload?.content) { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { content: event.payload.content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle toolUseEvent - if (eventType === "toolUseEvent" && event.payload) { - state.hasToolCalls = true; - const toolUse = event.payload; - const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; - - for (const singleToolUse of toolUses) { - const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; - const toolName = singleToolUse.name || ""; - const toolInput = singleToolUse.input; - - let toolIndex; - const isNewTool = !state.seenToolIds.has(toolCallId); - - if (isNewTool) { - toolIndex = state.toolCallIndex++; - state.seenToolIds.set(toolCallId, toolIndex); - - const startChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - ...(chunkIndex === 0 ? { role: "assistant" } : {}), - tool_calls: [{ - index: toolIndex, - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: "" - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)); - } else { - toolIndex = state.seenToolIds.get(toolCallId); - } - - if (toolInput !== undefined) { - let argumentsStr; - - if (typeof toolInput === 'string') { - argumentsStr = toolInput; - } else if (typeof toolInput === 'object') { - argumentsStr = JSON.stringify(toolInput); - } else { - continue; - } - - const argsChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: toolIndex, - function: { - arguments: argumentsStr - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`)); - } - } - } - - // Handle messageStopEvent - if (eventType === "messageStopEvent") { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - state.finishEmitted = true; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle contextUsageEvent to extract contextUsagePercentage - if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) { - state.contextUsagePercentage = event.payload.contextUsagePercentage; - // Mark that we received context usage event - state.hasContextUsage = true; - } - - // Handle meteringEvent - mark that we received it - if (eventType === "meteringEvent") { - state.hasMeteringEvent = true; - } - - // Handle metricsEvent for token usage - if (eventType === "metricsEvent") { - // Extract usage data from metricsEvent payload - const metrics = event.payload?.metricsEvent || event.payload; - if (metrics && typeof metrics === 'object') { - const inputTokens = metrics.inputTokens || 0; - const outputTokens = metrics.outputTokens || 0; - // ponytail: Amazon Q upstream does not expose cache fields today, - // but pick up cache_read_input_tokens / cache_creation_input_tokens - // if the event shape grows them so cost tracking stays accurate. - const cachedTokens = metrics.cacheReadInputTokens || metrics.cache_read_input_tokens || 0; - const cacheCreationInputTokens = metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens || 0; - - if (inputTokens > 0 || outputTokens > 0) { - state.usage = { - prompt_tokens: inputTokens, - completion_tokens: outputTokens, - total_tokens: inputTokens + outputTokens - }; - // Kiro is Claude-backed: inputTokens EXCLUDES cache (Claude convention), - // not inclusive like OpenAI's cached_tokens. Emit cache_read_input_tokens - // (not cached_tokens) so canonicalizeUsage takes the Claude fold path and - // correctly adds cache back into prompt_tokens instead of undercharging. - if (cachedTokens > 0) state.usage.cache_read_input_tokens = cachedTokens; - if (cacheCreationInputTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationInputTokens; - } - } - } - - // Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent - if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) { - state.finishEmitted = true; - - // Estimate tokens if not available from events - if (!state.usage) { - // Estimate output tokens from content length - const estimatedOutputTokens = state.totalContentLength > 0 - ? Math.max(1, Math.floor(state.totalContentLength / 4)) - : 0; - - // Estimate input tokens from contextUsagePercentage - const estimatedInputTokens = state.contextUsagePercentage > 0 - ? Math.floor(state.contextUsagePercentage * contextWindow / 100) - : 0; - - state.usage = { - prompt_tokens: estimatedInputTokens, - completion_tokens: estimatedOutputTokens, - total_tokens: estimatedInputTokens + estimatedOutputTokens - }; - } - - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - - // Include usage in final chunk if available - if (state.usage) { - finishChunk.usage = state.usage; - } - - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); } } - - if (iterations >= maxIterations) { - console.warn("[Kiro] Max iterations reached in event parsing"); + if (content || !state.hasReasoning) { + state.hasText ||= content.length > 0; + state.totalContentLength += content.length; + emitDelta(controller, { content }); } - - // No client chunk produced this frame — emit an SSE comment keepalive - // so the stall watchdog sees upstream activity (ignored by parser/client). - if (chunkIndex === enqueueCountBefore && !state.finishEmitted) { - controller.enqueue(new TextEncoder().encode(": ka\n\n")); - } - }, - - flush(controller) { - // Emit finish chunk if not already sent - if (!state.finishEmitted) { - state.finishEmitted = true; - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] + } else if (eventType === "reasoningContentEvent") { + const value = event.payload?.reasoningContentEvent || event.payload || {}; + const content = typeof value === "string" ? value : value.text || value.content || ""; + if (content) { + state.hasReasoning = true; + state.totalContentLength += content.length; + emitDelta(controller, { reasoning_content: content }); + } + } else if (eventType === "codeEvent" && typeof event.payload?.content === "string") { + state.hasCode = true; + state.totalContentLength += event.payload.content.length; + emitDelta(controller, { content: event.payload.content }); + } else if (eventType === "toolUseEvent") { + state.sawToolUse = true; + const values = Array.isArray(event.payload) ? event.payload : [event.payload]; + if (!values[0]) throw new Error("Kiro toolUseEvent is empty"); + for (const value of values) { + const name = typeof value?.name === "string" ? value.name.trim() : ""; + if (!name) throw new Error("Kiro toolUseEvent is missing a tool name"); + let id; + if (value.toolUseId == null) { + id = `call_${created}_${state.tools.size + 1}`; + } else if (typeof value.toolUseId !== "string" || !value.toolUseId.trim()) { + throw new Error("Kiro toolUseEvent has an invalid toolUseId"); + } else { + id = value.toolUseId; + } + let tool = state.tools.get(id); + if (!tool) { + tool = { id, name }; + state.tools.set(id, tool); + state.bufferedToolBytes += encoder.encode(id).byteLength + encoder.encode(name).byteLength + 32; + assertToolBufferBound(); + } else if (tool.name !== name) { + throw new Error("Kiro tool name changed between fragments"); + } + appendToolInput(tool, value.input); + } + } else if (eventType === "messageStopEvent") { + state.explicitStop = true; + const reason = normalizeStopReason( + event.payload?.stopReason ?? event.payload?.stop_reason + ) || (state.sawToolUse ? "tool_use" : "end_turn"); + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "message_stop_event"; + state.stopReason = merged; + } else if (eventType === "metadataEvent" || eventType === "MetadataEvent") { + const metadata = event.payload?.metadataEvent || event.payload?.metadata || event.payload; + const reason = normalizeStopReason(metadata?.stopReason ?? metadata?.stop_reason); + if (reason) { + state.explicitStop = true; + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "metadata_stop_reason"; + state.stopReason = merged; + } + } else if (eventType === "contextUsageEvent") { + const percentage = Number(event.payload?.contextUsagePercentage); + if (Number.isFinite(percentage)) { + state.contextUsagePercentage = percentage; + state.hasContextUsage = true; + } + } else if (eventType === "meteringEvent") { + state.hasMetering = true; + const metering = event.payload?.meteringEvent || event.payload || {}; + const credits = Number(metering.usage); + if (Number.isFinite(credits)) { + state.usage = { + ...(state.usage || {}), + kiro_credits: credits, + kiro_credit_unit: typeof metering.unit === "string" ? metering.unit : "credit" }; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); } + } else if (eventType === "metricsEvent") { + const metrics = event.payload?.metricsEvent || event.payload || {}; + const prompt = Number(metrics.inputTokens) || 0; + const completion = Number(metrics.outputTokens) || 0; + if (prompt || completion) { + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; + const cacheRead = Number(metrics.cacheReadInputTokens || metrics.cache_read_input_tokens) || 0; + const cacheCreate = Number(metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens) || 0; + if (cacheRead) state.usage.cache_read_input_tokens = cacheRead; + if (cacheCreate) state.usage.cache_creation_input_tokens = cacheCreate; + } + } + return true; + }; + const processBytes = (chunk, controller) => { + const combinedLength = state.buffer.byteLength + chunk.byteLength; + if (combinedLength > (options.maxRawBytes || EVENTSTREAM_MAX_MESSAGE_BYTES)) { + fail( + controller, + "corrupt_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream buffered bytes exceed the protocol bound" + ); + return false; + } + if (state.buffer.byteLength === 0) { + state.buffer = chunk; + } else { + const joined = new Uint8Array(combinedLength); + joined.set(state.buffer); + joined.set(chunk, state.buffer.byteLength); + state.buffer = joined; + } - // Send final done message - controller.enqueue(new TextEncoder().encode(SSE_DONE)); + while (state.buffer.byteLength >= 12) { + const view = new DataView(state.buffer.buffer, state.buffer.byteOffset); + if (view.getUint32(8, false) !== crc32(state.buffer.subarray(0, 8))) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream prelude CRC mismatch"); + return false; + } + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength < 16 || totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || headersLength > totalLength - 16) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream frame bounds are invalid"); + return false; + } + if (state.buffer.byteLength < totalLength) break; + const frame = state.buffer.slice(0, totalLength); + state.buffer = state.buffer.slice(totalLength); + let event; + try { + event = parseEventFrame(frame); + } catch (error) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", error.message); + return false; + } + state.transportState = "valid_complete_frame"; + state.validatedFrames++; + try { + if (!processEvent(event, controller)) return false; + } catch (error) { + const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED"; + if (!bufferExceeded) { + // Keep whatever is already buffered: the rejected fragment belongs to + // one tool, and clearing the map dropped the complete calls too. + state.toolValidationError ||= error.message; + console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`); + continue; + } + fail( + controller, + "integrity_buffer_exceeded", + "kiro_integrity_buffer_exceeded", + error.message, + { + transport_state: state.transportState, + stop_disposition: "terminal_incomplete" + } + ); + return false; + } + } + return true; + }; + const finish = (controller) => { + if (state.finished) return; + if (state.buffer.byteLength) { + fail( + controller, + "incomplete_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream ended with a truncated frame", + { transport_state: "incomplete_frame" } + ); + return; + } + state.transportState = "clean_eof"; + const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse); + // model_context_window_exceeded / max_tokens map to terminal_incomplete. When + // they arrive after the model already streamed content, fail() threw away a + // complete-enough answer; a truncated turn is what finish_reason "length" is + // for. chunkIndex > 0 means at least one delta already reached the client. + const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" && + KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0; + if (declaredTruncatedAfterOutput) { + console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`); + } + if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) { + const code = declaredDisposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : declaredDisposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : declaredDisposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: declaredDisposition } + ); + return; + } + try { + emitTools(controller); + } catch (error) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + error.message, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } + // Fail only when the turn has nothing usable left. emitTools() validates + // per tool and drops just the unusable ones, so this has to run AFTER it: + // before, the rejected tool was still buffered and tools.size was never 0. + // A turn that also produced text keeps that text -- the dropped call is + // logged, not fatal. + if (state.toolValidationError && !state.hasToolCalls && + !state.hasText && !state.hasReasoning && !state.hasCode) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + state.toolValidationError, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } + + const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls; + if (!hasOutput && !state.explicitStop) { + fail( + controller, + "empty_response_eof", + "kiro_missing_terminal", + "Kiro EventStream ended without model output", + { transport_state: state.transportState } + ); + return; + } + + const disposition = stopDisposition(state.stopReason, state.hasToolCalls); + // Same reasoning as declaredTruncatedAfterOutput above. + const truncatedAfterOutput = disposition === "terminal_incomplete" && + KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0; + if (truncatedAfterOutput) { + console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`); + } + if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) { + const code = disposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: disposition } + ); + return; + } + + if (state.hasMetering && state.hasContextUsage && !state.usage?.total_tokens) { + const completion = state.totalContentLength + ? Math.max(1, Math.floor(state.totalContentLength / 4)) + : 0; + const prompt = Math.floor(state.contextUsagePercentage * contextWindow / 100); + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; + } + const finishReason = truncatedAfterOutput + ? "length" + : state.hasToolCalls + ? "tool_calls" + : disposition === "length" + ? "length" + : "stop"; + controller.enqueue(sseChunk({}, finishReason, state.usage)); + controller.enqueue(encoder.encode(SSE_DONE)); + state.finished = true; + options.onTerminalState?.(diagnostics({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + // Report what this exit actually did, not the raw disposition. The + // integrity gate re-derives its verdict from stop_disposition, so + // reporting "terminal_incomplete" for a turn we deliberately kept made + // it discard the very bytes we just released to the client. + stop_disposition: truncatedAfterOutput ? "length" : disposition + })); + }; + + if (!response.body) { + const detail = diagnostics({ + terminal_provenance: "missing_response_body", + transport_state: "missing_body", + stop_disposition: "terminal_incomplete" + }); + options.onTerminalState?.(detail); + return new Response(encodeSSEError( + "kiro_missing_terminal", + "Kiro response did not include an EventStream body", + detail + ), { status: response.status, headers: { ...SSE_HEADERS } }); + } + + const reader = response.body.getReader(); + const stream = new ReadableStream({ + start: async (controller) => { + try { + while (!state.finished) { + const { done, value } = await reader.read(); + if (done) break; + const chunksBefore = state.chunkIndex; + const framesBefore = state.validatedFrames; + if (!processBytes(value, controller)) { + await reader.cancel("invalid Kiro EventStream").catch(() => {}); + break; + } + if (state.validatedFrames > framesBefore && state.chunkIndex === chunksBefore) { + controller.enqueue(encoder.encode(": kiro-upstream\n\n")); + } + } + finish(controller); + controller.close(); + } catch (error) { + if (!state.finished) { + fail( + controller, + "upstream_read_error", + "kiro_missing_terminal", + error.message || "Kiro EventStream read failed", + { transport_state: "upstream_error" } + ); + } + controller.close(); + } + }, + cancel(reason) { + return reader.cancel(reason); } }); - - // Pipe response body through transform stream - if (!response.body) { - return new Response(SSE_DONE, { status: response.status, headers: { "Content-Type": "text/event-stream" } }); - } - const transformedStream = response.body.pipeThrough(transformStream); - - return new Response(transformedStream, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { ...SSE_HEADERS } @@ -527,65 +1192,90 @@ export class KiroExecutor extends BaseExecutor { /** * Parse AWS EventStream frame */ + function parseEventFrame(data) { + if (!(data instanceof Uint8Array) || data.byteLength < 16) { + throw new Error("AWS EventStream frame is shorter than 16 bytes"); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength !== data.byteLength) { + throw new Error("AWS EventStream frame length does not match its prelude"); + } + if (totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || + headersLength > totalLength - 16) { + throw new Error("AWS EventStream frame bounds are invalid"); + } + if (view.getUint32(8, false) !== crc32(data.subarray(0, 8))) { + throw new Error("AWS EventStream prelude CRC mismatch"); + } + if (view.getUint32(totalLength - 4, false) !== crc32(data.subarray(0, totalLength - 4))) { + throw new Error("AWS EventStream message CRC mismatch"); + } + + const headers = Object.create(null); + const names = new Set(); + let offset = 12; + const headerEnd = offset + headersLength; + const requireBytes = (count) => { + if (offset + count > headerEnd) { + throw new Error("AWS EventStream header exceeds its declared bounds"); + } + }; + + while (offset < headerEnd) { + requireBytes(1); + const nameLength = data[offset++]; + requireBytes(nameLength + 1); + const name = decoder.decode(data.subarray(offset, offset + nameLength)); + offset += nameLength; + if (names.has(name)) throw new Error(`AWS EventStream contains duplicate header: ${name}`); + names.add(name); + const type = data[offset++]; + + if (type === 0 || type === 1) { + headers[name] = type === 0; + } else if (type === 2) { + requireBytes(1); + headers[name] = view.getInt8(offset); + offset += 1; + } else if (type === 3) { + requireBytes(2); + headers[name] = view.getInt16(offset, false); + offset += 2; + } else if (type === 4) { + requireBytes(4); + headers[name] = view.getInt32(offset, false); + offset += 4; + } else if (type === 5 || type === 8) { + requireBytes(8); + offset += 8; + } else if (type === 6 || type === 7) { + requireBytes(2); + const valueLength = view.getUint16(offset, false); + offset += 2; + requireBytes(valueLength); + const bytes = data.subarray(offset, offset + valueLength); + headers[name] = type === 7 ? decoder.decode(bytes) : bytes; + offset += valueLength; + } else if (type === 9) { + requireBytes(16); + offset += 16; + } else { + throw new Error(`AWS EventStream header ${name} has unknown type ${type}`); + } + } + + const payloadBytes = data.subarray(headerEnd, totalLength - 4); + if (payloadBytes.byteLength === 0) return { headers, payload: null }; + const payloadText = decoder.decode(payloadBytes); + if (!payloadText.trim()) return { headers, payload: null }; try { - const view = new DataView(data.buffer, data.byteOffset); - const headersLength = view.getUint32(4, false); - - // Parse headers - const headers = {}; - let offset = 12; // After prelude - const headerEnd = 12 + headersLength; - - while (offset < headerEnd && offset < data.length) { - const nameLen = data[offset]; - offset++; - if (offset + nameLen > data.length) break; - - const name = new TextDecoder().decode(data.slice(offset, offset + nameLen)); - offset += nameLen; - - const headerType = data[offset]; - offset++; - - if (headerType === 7) { // String type - const valueLen = (data[offset] << 8) | data[offset + 1]; - offset += 2; - if (offset + valueLen > data.length) break; - - const value = new TextDecoder().decode(data.slice(offset, offset + valueLen)); - offset += valueLen; - headers[name] = value; - } else { - break; - } - } - - // Parse payload - const payloadStart = 12 + headersLength; - const payloadEnd = data.length - 4; // Exclude message CRC - - let payload = null; - if (payloadEnd > payloadStart) { - const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd)); - - // Skip empty or whitespace-only payloads - if (!payloadStr || !payloadStr.trim()) { - return { headers, payload: null }; - } - - try { - payload = JSON.parse(payloadStr); - } catch (parseError) { - // Log parse error for debugging - console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`); - payload = { raw: payloadStr }; - } - } - - return { headers, payload }; - } catch { - return null; + return { headers, payload: JSON.parse(payloadText) }; + } catch (error) { + throw new Error(`AWS EventStream payload is not valid JSON (${error.message})`); } } diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js deleted file mode 100644 index 7bf47edb..00000000 --- a/open-sse/executors/opencode-go.js +++ /dev/null @@ -1,49 +0,0 @@ -import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; -import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; -import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; - -// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) -const MESSAGES_FORMAT_MODELS = new Set([ - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", -]); - -const BASE = "https://opencode.ai/zen/go/v1"; - -export class OpenCodeGoExecutor extends BaseExecutor { - constructor() { - super("opencode-go", PROVIDERS["opencode-go"]); - } - - // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here - buildUrl(model) { - this._lastModel = model; - return MESSAGES_FORMAT_MODELS.has(model) - ? `${BASE}/messages` - : `${BASE}/chat/completions`; - } - - buildHeaders(credentials, stream = true) { - const key = credentials?.apiKey || credentials?.accessToken; - const headers = { "Content-Type": "application/json" }; - - if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) { - headers["x-api-key"] = key; - headers["anthropic-version"] = ANTHROPIC_API_VERSION; - } else { - headers["Authorization"] = `Bearer ${key}`; - } - - if (stream) headers["Accept"] = "text/event-stream"; - return headers; - } - - transformRequest(model, body) { - return injectReasoningContent({ provider: this.provider, model, body }); - } -} diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index f7aee211..27a81c00 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,16 +1,43 @@ +import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; -// Models that use /zen/v1/messages (claude format) +const OPENCODE_UA = "opencode"; const MESSAGES_MODELS = new Set(); +function generateRequestId() { + return `msg_${crypto.randomUUID().replace(/-/g, "")}`; +} + +function generateSessionId() { + return `ses_${crypto.randomUUID().replace(/-/g, "")}`; +} + +// Normalize any resolved id into opencode's ses_ format (stable per-conversation) +function toOpencodeSession(id) { + const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, ""); + return stripped ? `ses_${stripped}` : null; +} + +function resolveOpencodeSession(body, credentials) { + return toOpencodeSession(resolveSessionId({ + headers: credentials?.rawHeaders, + body, + connectionId: credentials?.connectionId, + scope: "opencode", + })); +} + export class OpenCodeExecutor extends BaseExecutor { constructor() { super("opencode", PROVIDERS.opencode); + this._currentSessionId = null; } - transformRequest(model, body) { + transformRequest(model, body, stream, credentials) { + this._currentSessionId = resolveOpencodeSession(body, credentials); return injectReasoningContent({ provider: this.provider, model, body }); } @@ -21,12 +48,23 @@ export class OpenCodeExecutor extends BaseExecutor { : `${base}/zen/v1/chat/completions`; } - buildHeaders() { + buildHeaders(credentials, stream = true) { + const raw = credentials?.rawHeaders || {}; + const lower = {}; + for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; + + const downstreamUa = lower["user-agent"] || ""; + const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); + return { "Content-Type": "application/json", "Authorization": "Bearer public", - "x-opencode-client": "desktop", - "Accept": "text/event-stream" + "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, + "x-opencode-client": lower["x-opencode-client"] || "desktop", + "x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(), + "x-opencode-request": lower["x-opencode-request"] || generateRequestId(), + "x-opencode-project": lower["x-opencode-project"] || "global", + "Accept": stream ? "text/event-stream" : "*/*", }; } } diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 8b166089..e52a00df 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -32,9 +32,11 @@ import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { QODER_CHAT_URL_ENCODED, + QODER_CHAT_BASE_ALT, + QODER_CHAT_SIG_PATH, QODER_MODEL_MAP, } from "../shared/qoder/constants.js"; -import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; +import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js"; /** * Hoist role:"system" messages out of the messages array (Qoder rejects @@ -213,6 +215,52 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio }; } +/** + * Check if a qoder error message indicates a billing/quota block. + * Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field. + */ +function isBillingBlock(inner) { + if (!inner || typeof inner !== "string") return false; + const lowerMsg = inner.toLowerCase(); + // Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field + return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl"); +} + +/** + * Peek the first SSE frame to detect billing errors before piping. + * Returns { isBilling, statusVal, message, consumed } — `consumed` is every + * byte read so far (including the peeked line) so the caller can re-process + * it and nothing is dropped from the stream. + */ +async function peekFirstQoderFrame(reader, decoder) { + let consumed = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) return { isBilling: false, consumed, upstreamDone: true }; + + consumed += decoder.decode(value, { stream: true }); + const nl = consumed.indexOf("\n"); + if (nl === -1) continue; // need a full line first + + const line = consumed.slice(0, nl).replace(/\r$/, "").trim(); + if (!line.startsWith("data:")) continue; + + const data = line.slice(5).trimStart(); + if (data === "[DONE]") return { isBilling: false, consumed }; + + let envelope; + try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; } + + const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; + const inner = typeof envelope.body === "string" ? envelope.body : ""; + + if (statusVal !== 200 && isBillingBlock(inner)) { + return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` }; + } + return { isBilling: false, consumed }; + } +} + /** * Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain * OpenAI SSE chunks the rest of the chatCore pipeline understands. @@ -220,73 +268,42 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * Each upstream line looks like: * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it - * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus - * a synthetic OpenAI error chunk. + * and re-emit as `data: \n\n`. Errors become a synthetic OpenAI error + * chunk + [DONE]. + * + * Critical: Qoder's SSE often keeps the socket open after the terminal + * [DONE]/error frame (agent keepalive). Non-streaming clients drain via + * response.text() which hangs until the socket closes — so on terminal + * events we cancel the upstream reader and close our stream immediately. + * + * NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl). + * If detected, return 403 response so chatCore marks connection unavailable + * and triggers combo fallback instead of leaking error text into chat. */ async function wrapQoderSSE(response, model) { if (!response.ok || !response.body) return response; const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - - // Peek at first chunk to detect errors early const reader = response.body.getReader(); - const firstRead = await reader.read(); - - if (firstRead.done) { - // Empty stream - return new Response("data: [DONE]\n\n", { - status: response.status, - statusText: response.statusText, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - }, - }); + + // Peek first frame to detect billing block + const peek = await peekFirstQoderFrame(reader, decoder); + if (peek?.isBilling) { + // Billing block detected — return 403 so chatCore fails this connection + await reader.cancel().catch(() => {}); + return new Response( + JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); } - // Parse first line to check for error - const firstText = decoder.decode(firstRead.value, { stream: true }); - const nlIndex = firstText.indexOf("\n"); - const firstLine = nlIndex !== -1 ? firstText.slice(0, nlIndex) : firstText; - const trimmed = firstLine.replace(/\r$/, "").trim(); - - if (trimmed.startsWith("data:")) { - const data = trimmed.slice(5).trimStart(); - if (data !== "[DONE]") { - try { - const envelope = JSON.parse(data); - const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; - - if (statusVal !== 200) { - // Error detected - return error Response to trigger failover - const msg = envelope.body || `upstream status ${statusVal}`; - const errorResponse = new Response( - JSON.stringify({ - error: { - message: `qoder error ${statusVal}: ${truncate(msg, 500)}`, - type: "upstream_error", - code: String(statusVal) - } - }), - { - status: statusVal >= 400 && statusVal < 600 ? statusVal : 502, - headers: { "Content-Type": "application/json" } - } - ); - reader.cancel(); - return errorResponse; - } - } catch (e) { - // Not JSON, continue as normal stream - } - } - } - - // No error detected - proceed with normal TransformStream - let buffer = ""; + // Normal flow: re-process every byte the peek consumed, then continue. + let buffer = peek.consumed || ""; + const upstreamDrained = peek.upstreamDone === true; + const encoder = new TextEncoder(); let doneEmitted = false; + // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { const trimmed = line.replace(/\r$/, "").trim(); if (!trimmed) return; @@ -324,53 +341,81 @@ async function wrapQoderSSE(response, model) { doneEmitted = true; return; } + // Strip embedded newlines so the SSE frame stays a single event. const sanitized = inner.replace(/\r?\n/g, ""); controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); }; - const transform = new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }); - let nl; - while ((nl = buffer.indexOf("\n")) !== -1) { - const line = buffer.slice(0, nl); - buffer = buffer.slice(nl + 1); - processLine(line, controller); - } - }, - flush(controller) { - buffer += decoder.decode(); - if (buffer.length > 0) { - processLine(buffer, controller); - buffer = ""; - } - if (!doneEmitted) { - controller.enqueue(encoder.encode(SSE_DONE)); - doneEmitted = true; - } - }, - }); + const stream = new ReadableStream({ + // Use start()+loop (not pull): a pull that buffers a partial line without + // enqueueing would never be re-invoked, hanging consumers like .text(). + async start(controller) { + try { + // Drain whatever the peek already pulled off the socket first. + let nlSeed; + while ((nlSeed = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nlSeed); + buffer = buffer.slice(nlSeed + 1); + processLine(line, controller); + if (doneEmitted) { + await reader.cancel().catch(() => {}); + controller.close(); + return; + } + } + if (upstreamDrained) { + // Peek hit end-of-stream: flush any trailing partial line. + buffer += decoder.decode(); + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + } - // Create a ReadableStream that emits the first chunk + remaining chunks - const combinedStream = new ReadableStream({ - start(controller) { - controller.enqueue(firstRead.value); - }, - async pull(controller) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - } else { - controller.enqueue(value); + while (!doneEmitted && !upstreamDrained) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + if (doneEmitted) { + // Terminal frame received — drop upstream keepalive and end. + await reader.cancel().catch(() => {}); + controller.close(); + return; + } + } + } + } catch { + // fall through to terminal [DONE] + close + } finally { + if (!doneEmitted) { + try { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + } catch { /* already closed */ } + } + try { controller.close(); } catch { /* already closed */ } + await reader.cancel().catch(() => {}); } }, cancel() { - reader.cancel(); - } + return reader.cancel().catch(() => {}); + }, }); - const transformed = combinedStream.pipeThrough(transform); - return new Response(transformed, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { @@ -385,7 +430,13 @@ export class QoderExecutor extends BaseExecutor { super("qoder", PROVIDERS.qoder); } - buildUrl() { + buildUrl(credentials) { + // Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt- + // with "Login expired" (403). Device tokens (dt-...) stay on api3. + const raw = credentials?.apiKey || credentials?.accessToken; + if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) { + return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; + } return QODER_CHAT_URL_ENCODED; } @@ -395,8 +446,24 @@ export class QoderExecutor extends BaseExecutor { // - COSY headers built from the *encoded* body bytes // - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { - const url = this.buildUrl(); + // PAT (pt-...) → exchange for short-lived job token + resolve userId so + // downstream COSY signing + catalog fetch work. Device tokens (dt-...) and + // job tokens (jt-...) skip this and are used directly. + const rawToken = credentials?.apiKey || credentials?.accessToken; + if (isQoderPat(rawToken)) { + try { + credentials = await resolveQoderCredentials(credentials, proxyOptions, signal); + } catch (err) { + log?.error?.("QODER", `PAT exchange failed: ${err.message}`); + const fakeResp = new Response( + JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url: this.buildUrl(credentials), headers: {}, transformedBody: body }; + } + } + const url = this.buildUrl(credentials); const psd = credentials?.providerSpecificData || {}; if (!psd.userId) { // No user id → no way to sign. Surface a 401 so the dashboard nudges @@ -514,4 +581,5 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, + isBillingBlock, }; diff --git a/open-sse/executors/qwen.js b/open-sse/executors/qwen.js deleted file mode 100644 index ae828118..00000000 --- a/open-sse/executors/qwen.js +++ /dev/null @@ -1,129 +0,0 @@ -import { DefaultExecutor } from "./default.js"; -import { PROVIDERS } from "../config/providers.js"; -import { OAUTH_ENDPOINTS } from "../config/appConstants.js"; - -/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */ -const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)"; -const QWEN_STAINLESS = { - os: "Linux", - arch: "x64", - lang: "js", - runtime: "node", - runtimeVersion: "v18.19.1", - packageVersion: "5.11.0", - retryCount: "1" -}; -const QWEN_DEFAULT_SYSTEM_MESSAGE = { - role: "system", - content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }] -}; - -function ensureQwenSystemMessage(body) { - if (!body || typeof body !== "object") return body; - const next = { ...body }; - if (Array.isArray(next.messages)) { - next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages]; - } else { - next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE]; - } - return next; -} - -function isQwenThinkingActive(body) { - const thinking = body?.thinking; - if (thinking === true || body?.enable_thinking === true) return true; - return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled"; -} - -// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto". -function sanitizeQwenThinkingToolChoice(body) { - if (!isQwenThinkingActive(body)) return body; - const tc = body.tool_choice; - const incompatible = tc === "required" || (typeof tc === "object" && tc !== null); - if (!incompatible) return body; - return { ...body, tool_choice: "auto" }; -} - -function buildQwenUpstreamHeaders(credentials, stream = true) { - const token = credentials?.apiKey || credentials?.accessToken || ""; - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "User-Agent": QWEN_USER_AGENT, - "X-DashScope-AuthType": "qwen-oauth", - "X-DashScope-CacheControl": "enable", - "X-DashScope-UserAgent": QWEN_USER_AGENT, - "X-Stainless-Arch": QWEN_STAINLESS.arch, - "X-Stainless-Lang": QWEN_STAINLESS.lang, - "X-Stainless-Os": QWEN_STAINLESS.os, - "X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion, - "X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount, - "X-Stainless-Runtime": QWEN_STAINLESS.runtime, - "X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion, - Connection: "keep-alive", - "Accept-Language": "*", - "Sec-Fetch-Mode": "cors" - }; - headers.Accept = stream ? "text/event-stream" : "application/json"; - return headers; -} - -export class QwenExecutor extends DefaultExecutor { - constructor() { - super("qwen"); - } - - // Qwen tokens are bound to a resource_url returned at OAuth time. - // Using portal.qwen.ai when the token is issued for another shard returns 401/403. - buildUrl(model, stream, urlIndex = 0, credentials = null) { - const resourceUrl = credentials?.providerSpecificData?.resourceUrl; - const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai"; - return `https://${host}/v1/chat/completions`; - } - - buildHeaders(credentials, stream = true) { - return buildQwenUpstreamHeaders(credentials, stream); - } - - transformRequest(model, body, stream, credentials) { - let next = body && typeof body === "object" ? { ...body } : body; - if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) { - next.stream_options = { include_usage: true }; - } - next = sanitizeQwenThinkingToolChoice(next); - return ensureQwenSystemMessage(next); - } - - // Override to capture resource_url from refresh response (required for buildUrl). - async refreshCredentials(credentials, log) { - if (!credentials?.refreshToken) return null; - try { - const response = await fetch(OAUTH_ENDPOINTS.qwen.token, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: credentials.refreshToken, - client_id: PROVIDERS.qwen.clientId - }) - }); - if (!response.ok) return null; - const tokens = await response.json(); - log?.info?.("TOKEN", "qwen refreshed"); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || credentials.refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: { - ...(credentials.providerSpecificData || {}), - ...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {}) - } - }; - } catch (error) { - log?.error?.("TOKEN", `qwen refresh error: ${error.message}`); - return null; - } - } -} - -export default QwenExecutor; diff --git a/open-sse/executors/trae.js b/open-sse/executors/trae.js new file mode 100644 index 00000000..59f29be3 --- /dev/null +++ b/open-sse/executors/trae.js @@ -0,0 +1,339 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; + +// Trae executor — SOLO remote agent API. +// +// Flow: +// 1. POST {base}/chat_sessions → { code:0, 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). +// Identity fields for common_params live in credentials.providerSpecificData. + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +const TRAE_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function flattenQuery(messages) { + const parts = []; + 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.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); + } + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]); +} + +export default class TraeExecutor extends BaseExecutor { + constructor() { + super("trae", PROVIDERS.trae); + } + + base() { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || ""; + const psd = credentials?.providerSpecificData || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": psd.appLanguage || "en", + "x-user-region": psd.userRegion || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": TRAE_UA, + Accept: stream ? "text/event-stream" : "application/json", + }; + } + + // SOLO session modes: "code" (model picker) vs "work" (fast auto lane). + resolveMode(model) { + 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 }; + } + + // common_params is a JSON-encoded string embedded inside initial_message. + commonParams(psd, mode, sessionId) { + const cp = { + language: "en-us", + app_language: psd.appLanguage || "en", + quality: "stable", + app_version: psd.appVersion || "1.0.0.1229", + web_id: psd.webId || "", + user_identity: psd.userIdentity || "Free", + is_freshman: "0", + biz_user_id: psd.bizUserId || "", + user_unique_id: psd.userUniqueId || "", + scope: psd.scope || "marscode-us", + tenant: psd.tenant || "marscode", + region: psd.region || "US-East", + aiRegion: psd.aiRegion || psd.region || "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. + async createSession(headers, query, model, psd, signal) { + 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 proxyAwareFetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }, null); + 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. + async streamEvents(headers, sessionId, replyTo, onEvent, signal) { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + 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 proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null); + 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 = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + 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; + 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 }) { + const headers = this.buildHeaders(credentials, stream !== false); + const psd = credentials?.providerSpecificData || {}; + const query = flattenQuery(body?.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status, message) => new Response( + JSON.stringify({ error: { message, type: "api_error", code: "" } }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session; + try { + session = await this.createSession(headers, query, model, psd, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order = []; + const thoughts = {}; + let sent = 0; + let usage = null; + let errorEvent = null; + const renderNewText = (data) => { + const pid = data.id; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = data.thought || ""; + 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) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + 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") { + 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); + 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); + } catch (err) { + return { response: errResponse(502, err?.message ? String(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 = { + 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, + }; + } + + // Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via + // ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js. + async refreshCredentials() { + return null; + } +} diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js new file mode 100644 index 00000000..8526df17 --- /dev/null +++ b/open-sse/executors/windsurf.js @@ -0,0 +1,588 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; +import { randomUUID } from "node:crypto"; + +// WindsurfExecutor — Codeium gRPC-web chat. +// +// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto). +// Service: exa.language_server_pb.LanguageServerService +// Method: GetChatMessage (unary request → streamed CompletionChunk frames) +// +// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived) +// — placed in Metadata.api_key protobuf field of every request + Bearer header. + +const WS_BASE_URL = "https://server.codeium.com"; +const WS_SERVICE = "exa.language_server_pb.LanguageServerService"; +const WS_METHOD_CHAT = "GetChatMessage"; +const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`; + +const WS_IDE_NAME = "windsurf"; +const WS_IDE_VERSION = "3.14.0"; +const WS_EXT_VERSION = "3.14.0"; +const WS_LOCALE = "en-US"; + +// ─── Model alias map (catalog name → Windsurf wire name) ───────────────────── +const MODEL_ALIAS_MAP = { + // ── Cognition SWE ─────────────────────────────────────────────────────── + "swe-1.6-fast": "swe-1-6-fast", + "swe-1.6": "swe-1-6", + "swe-1.5-fast": "swe-1-5-fast", + "swe-1.5": "swe-1-5", + // ── Claude Opus 4.7 — effort-tiered ───────────────────────────────────── + "claude-opus-4.7-max": "claude-opus-4-7-max", + "claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh", + "claude-opus-4.7-high": "claude-opus-4-7-high", + "claude-opus-4.7-medium": "claude-opus-4-7-medium", + "claude-opus-4.7-low": "claude-opus-4-7-low", + "claude-opus-4.7-review": "opus-4-7-review", + // ── Claude Opus/Sonnet 4.6 ────────────────────────────────────────────── + "claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m", + "claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m", + "claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-opus-4.6-thinking": "claude-opus-4-6-thinking", + "claude-opus-4.6": "claude-opus-4-6", + // ── Claude 4.5 ────────────────────────────────────────────────────────── + "claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING", + "claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS", + "claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3", + "claude-sonnet-4.5": "MODEL_PRIVATE_2", + "claude-haiku-4.5": "MODEL_PRIVATE_11", + // ── GPT-5.5 ───────────────────────────────────────────────────────────── + "gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority", + "gpt-5.5-high-fast": "gpt-5-5-high-priority", + "gpt-5.5-medium-fast": "gpt-5-5-medium-priority", + "gpt-5.5-low-fast": "gpt-5-5-low-priority", + "gpt-5.5-none-fast": "gpt-5-5-none-priority", + "gpt-5.5-xhigh": "gpt-5-5-xhigh", + "gpt-5.5-high": "gpt-5-5-high", + "gpt-5.5-medium": "gpt-5-5-medium", + "gpt-5.5-low": "gpt-5-5-low", + "gpt-5.5-none": "gpt-5-5-none", + "gpt-5.5-review": "gpt-5-5-review", + "gpt-5.5": "gpt-5-5-medium", + // ── GPT-5.4 ───────────────────────────────────────────────────────────── + "gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority", + "gpt-5.4-high-fast": "gpt-5-4-high-priority", + "gpt-5.4-medium-fast": "gpt-5-4-medium-priority", + "gpt-5.4-low-fast": "gpt-5-4-low-priority", + "gpt-5.4-none-fast": "gpt-5-4-none-priority", + "gpt-5.4-xhigh": "gpt-5-4-xhigh", + "gpt-5.4-high": "gpt-5-4-high", + "gpt-5.4-medium": "gpt-5-4-medium", + "gpt-5.4-low": "gpt-5-4-low", + "gpt-5.4-none": "gpt-5-4-none", + "gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh", + "gpt-5.4-mini-high": "gpt-5-4-mini-high", + "gpt-5.4-mini-medium": "gpt-5-4-mini-medium", + "gpt-5.4-mini-low": "gpt-5-4-mini-low", + "gpt-5.4": "gpt-5-4-medium", + // ── GPT-5.3-Codex ─────────────────────────────────────────────────────── + "gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority", + "gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority", + "gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority", + "gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority", + "gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh", + "gpt-5.3-codex-high": "gpt-5-3-codex-high", + "gpt-5.3-codex-medium": "gpt-5-3-codex-medium", + "gpt-5.3-codex-low": "gpt-5-3-codex-low", + "gpt-5.3-codex": "gpt-5-3-codex-medium", + // ── GPT-5.2 ───────────────────────────────────────────────────────────── + "gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH", + "gpt-5.2-high": "MODEL_GPT_5_2_HIGH", + "gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM", + "gpt-5.2-low": "MODEL_GPT_5_2_LOW", + "gpt-5.2-none": "MODEL_GPT_5_2_NONE", + "gpt-5.2": "MODEL_GPT_5_2_MEDIUM", + // ── GPT-5 ─────────────────────────────────────────────────────────────── + "gpt-5": "gpt-5", + // ── GPT-4.1 / 4o ──────────────────────────────────────────────────────── + "gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14", + "gpt-4.1-mini": "gpt-4.1-mini", + "gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06", + // ── Gemini ────────────────────────────────────────────────────────────── + "gemini-3.1-pro-high": "gemini-3-1-pro-high", + "gemini-3.1-pro-low": "gemini-3-1-pro-low", + "gemini-3.1-pro": "gemini-3-1-pro-high", + "gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", + "gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW", + "gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", + "gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO", + // ── Others ────────────────────────────────────────────────────────────── + "deepseek-v4": "deepseek-v4", + "kimi-k2.6": "kimi-k2-6", + "kimi-k2.5": "kimi-k2-5", + "glm-5.1": "glm-5-1", +}; + +export function resolveWsModelId(model) { + return MODEL_ALIAS_MAP[model] ?? model; +} + +// ─── Minimal protobuf encoder ──────────────────────────────────────────────── +// Wire types: 0 = varint, 2 = length-delimited. + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80); + v >>>= 7; + } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} + +function concatBytes(arrays) { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +const TEXT_ENC = new TextEncoder(); +const TEXT_DEC = new TextDecoder(); + +function encodeField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + return concatBytes([tag, len, payload]); +} + +function encodeString(fieldNum, value) { + return encodeField(fieldNum, TEXT_ENC.encode(value)); +} + +function encodeMessage(fieldNum, msg) { + return encodeField(fieldNum, msg); +} + +// ─── Protobuf message builders ─────────────────────────────────────────────── + +function buildMetadata(apiKey, sessionId) { + return concatBytes([ + encodeString(1, apiKey), + encodeString(2, WS_IDE_NAME), + encodeString(3, WS_IDE_VERSION), + encodeString(4, WS_EXT_VERSION), + encodeString(5, sessionId), + encodeString(6, WS_LOCALE), + ]); +} + +function buildModelOrAlias(model) { + return encodeString(1, model); +} + +function buildChatMessage(msg) { + const parts = [encodeString(1, msg.role), encodeString(2, msg.content)]; + if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId)); + return concatBytes(parts); +} + +export function buildGetChatMessageRequest(apiKey, model, messages) { + const sessionId = randomUUID(); + const cascadeId = randomUUID(); + + const parts = [ + encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata + encodeString(2, cascadeId), // cascade_id + encodeMessage(3, buildModelOrAlias(model)), // model_or_alias + ]; + + for (const msg of messages) { + parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages + } + + return concatBytes(parts); +} + +// ─── gRPC-web framing ──────────────────────────────────────────────────────── + +export function grpcWebFrame(payload) { + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x00; // no compression + const view = new DataView(frame.buffer); + view.setUint32(1, payload.length, false); // big-endian length + frame.set(payload, 5); + return frame; +} + +// ─── Protobuf response decoder ─────────────────────────────────────────────── +// CompletionChunk (oneof): +// field 1 → ContentChunk { field 1: string text } +// field 2 → ToolCallChunk (skipped) +// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } } +// field 4 → ErrorChunk { field 1: string message } + +function readVarint(buf, offset) { + let result = 0; + let shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return [result >>> 0, offset]; +} + +function decodeStringField(buf, targetField) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + if (fieldNum === targetField) return TEXT_DEC.decode(payload); + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return null; +} + +function decodeDoneChunk(buf) { + // DoneChunk: field 1 = UsageStats (nested) + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + let offset = 0; + let usageBytes = null; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len); + offset += len; + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else { + break; + } + } + if (!usageBytes) return [0, 0]; + let promptTokens = 0; + let completionTokens = 0; + offset = 0; + while (offset < usageBytes.length) { + let tag; + [tag, offset] = readVarint(usageBytes, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 0) { + let v; + [v, offset] = readVarint(usageBytes, offset); + if (fieldNum === 1) promptTokens = v; + else if (fieldNum === 2) completionTokens = v; + } else if (wireType === 2) { + let len; + [len, offset] = readVarint(usageBytes, offset); + offset += len; + } else { + break; + } + } + return [promptTokens, completionTokens]; +} + +export function decodeCompletionChunk(buf) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + + if (fieldNum === 1) { + const text = decodeStringField(payload, 1); + if (text !== null) return { kind: "content", text }; + } else if (fieldNum === 3) { + const usage = decodeDoneChunk(payload); + return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] }; + } else if (fieldNum === 4) { + const msg = decodeStringField(payload, 1); + return { kind: "error", message: msg ?? "unknown windsurf error" }; + } + // field 2 = ToolCallChunk — not yet handled; skip + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return { kind: "unknown" }; +} + +// ─── OpenAI messages → Windsurf wire ───────────────────────────────────────── + +function openAIMessagesToWs(messages) { + const out = []; + for (const m of messages) { + const role = String(m.role || "user"); + let content = ""; + if (typeof m.content === "string") { + content = m.content; + } else if (Array.isArray(m.content)) { + for (const part of m.content) { + if (part && typeof part === "object" && part.type === "text") { + content += String(part.text || ""); + } + } + } + out.push({ role, content, toolCallId: m.tool_call_id }); + } + return out; +} + +// ─── WindsurfExecutor ──────────────────────────────────────────────────────── + +export class WindsurfExecutor extends BaseExecutor { + constructor() { + super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL }); + } + + buildUrl() { + return WS_CHAT_URL; + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || credentials?.apiKey || ""; + return { + "Content-Type": "application/grpc-web+proto", + Accept: "application/grpc-web+proto", + // Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "User-Agent": `windsurf/${WS_IDE_VERSION}`, + "X-Grpc-Web": "1", + }; + } + + // Request body is built manually in execute() — requires model + messages. + transformRequest() { + return null; + } + + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) { + const apiKey = credentials?.accessToken || credentials?.apiKey || ""; + const wsModel = resolveWsModelId(model); + + const b = body ?? {}; + const rawMessages = Array.isArray(b.messages) ? b.messages : []; + let wsMessages = openAIMessagesToWs(rawMessages); + if (wsMessages.length === 0) { + wsMessages.push({ role: "user", content: "" }); + } + + const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages); + const framedPayload = grpcWebFrame(protoPayload); + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders); + + log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`); + + const upstream = await proxyAwareFetch(url, { + method: "POST", + headers, + body: framedPayload, + signal, + }, proxyOptions); + + if (!upstream.ok && upstream.status !== 200) { + return { response: upstream, url, headers, transformedBody: protoPayload }; + } + + const sseResponse = this.transformToSSE(upstream, model); + return { response: sseResponse, url, headers, transformedBody: protoPayload }; + } + + // Convert a gRPC-web binary response into an OpenAI-compatible SSE stream. + transformToSSE(upstream, model) { + const responseId = `chatcmpl-ws-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const executor = this; + + const sseStream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + let roleEmitted = false; + let totalText = ""; + let promptTokens = 0; + let completionTokens = 0; + let hadError = null; + + const emit = (data) => controller.enqueue(enc.encode(data)); + + try { + let pending = new Uint8Array(0); + const reader = upstream.body?.getReader(); + + const handleFrame = (flag, payload) => { + if (flag === 0x80) { + // Trailer frame — contains grpc-status, grpc-message + const trailer = TEXT_DEC.decode(payload); + const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer); + if (statusMatch && statusMatch[1] !== "0") { + const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer); + hadError = msgMatch + ? decodeURIComponent(msgMatch[1].trim()) + : `gRPC status ${statusMatch[1]}`; + } + return; + } + if (flag !== 0x00) return; // skip unknown flags + + const chunk = executor.constructor.decodeCompletionChunk + ? executor.constructor.decodeCompletionChunk(payload) + : decodeCompletionChunk(payload); + + if (chunk.kind === "content" && chunk.text) { + totalText += chunk.text; + if (!roleEmitted) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + roleEmitted = true; + } + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }], + })}\n\n`); + } else if (chunk.kind === "done") { + promptTokens = chunk.promptTokens; + completionTokens = chunk.completionTokens; + } else if (chunk.kind === "error") { + hadError = chunk.message; + } + }; + + const drainFrames = () => { + let offset = 0; + while (offset + 5 <= pending.length) { + const flag = pending[offset]; + const len = + (pending[offset + 1] << 24) | + (pending[offset + 2] << 16) | + (pending[offset + 3] << 8) | + pending[offset + 4]; + if (len < 0 || offset + 5 + len > pending.length) break; + handleFrame(flag, pending.slice(offset + 5, offset + 5 + len)); + offset += 5 + len; + } + if (offset > 0) pending = pending.slice(offset); + }; + + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + pending = pending.length === 0 ? value : concatBytes([pending, value]); + drainFrames(); + } + } finally { + reader.releaseLock(); + } + } + drainFrames(); + + if (hadError) { + emit(`data: ${JSON.stringify({ + error: { message: hadError, type: "windsurf_error", code: "upstream_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + controller.close(); + return; + } + + // Unary fallback: nothing streamed but text decoded → emit as one chunk. + if (!roleEmitted && totalText) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }], + })}\n\n`); + } + + const finishPayload = { + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + if (promptTokens > 0 || completionTokens > 0) { + finishPayload.usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; + } + emit(`data: ${JSON.stringify(finishPayload)}\n\n`); + emit("data: [DONE]\n\n"); + } catch (err) { + const msg = err?.message ? String(err.message) : String(err); + emit(`data: ${JSON.stringify({ + error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + } + + controller.close(); + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band. + async refreshCredentials() { + return null; + } +} + +export default WindsurfExecutor; diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js new file mode 100644 index 00000000..e6233fcb --- /dev/null +++ b/open-sse/executors/zed.js @@ -0,0 +1,304 @@ +// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator +// (cloud.zed.dev/completions), a multi-format proxy fronting +// Anthropic/OpenAI/Google/xAI depending on the requested model. +// +// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line +// response stream (`{"event": }` / `{"status": ...}` / +// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from +// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The +// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped) +// depending on which upstream Zed fronts for the model — translated back to +// OpenAI Chat Completions by reusing the existing translators. +// +// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire +// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't +// fit the generic transformRequest/buildUrl contract. + +import { BaseExecutor } from "./base.js"; +import { FORMATS } from "../translator/formats.js"; +import { initState } from "../translator/index.js"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; +import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js"; +import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; +import { + ZED_HEADERS, + resolveZedModels, + zedLlmFetch, +} from "../shared/zedAuth.js"; + +const ZED_PROVIDER = { + anthropic: "Anthropic", + openai: "OpenAi", + google: "Google", + xai: "XAi", +}; + +function normalizeZedProvider(value, model) { + const raw = String(value || "").toLowerCase(); + if (raw === "anthropic") return ZED_PROVIDER.anthropic; + if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai; + if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google; + if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai; + + const m = String(model || "").toLowerCase(); + if (m.includes("claude")) return ZED_PROVIDER.anthropic; + if (m.includes("gemini")) return ZED_PROVIDER.google; + if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai; + return ZED_PROVIDER.openai; +} + +function buildProviderRequest(provider, model, body, stream, credentials) { + if (provider === ZED_PROVIDER.anthropic) { + return openaiToClaudeRequest(model, body, true); + } + if (provider === ZED_PROVIDER.google) { + return openaiToGeminiRequest(model, body, true); + } + if (provider === ZED_PROVIDER.openai) { + return openaiToOpenAIResponsesRequest(model, body, true, credentials); + } + // xAI is OpenAI-shaped — forward as-is. + return { ...(body || {}), model, stream: stream !== false }; +} + +function initProviderState(provider, model) { + if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE); + if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI); + if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES); + const state = initState(FORMATS.OPENAI); + state.model = model; + return state; +} + +function convertProviderEvent(provider, event, state) { + if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state); + return event; +} + +function createErrorChunk(model, message) { + return { + id: `chatcmpl-zed-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }, + ], + }; +} + +function enqueueSseObject(controller, encoder, chunk) { + if (!chunk) return; + const items = Array.isArray(chunk) ? chunk : [chunk]; + for (const item of items) { + if (!item) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`)); + } +} + +function unwrapZedLine(line) { + let text = line.replace(/\r$/, "").trim(); + if (!text) return null; + if (text.startsWith("data:")) text = text.slice(5).trimStart(); + if (text === "[DONE]") return { done: true }; + try { + const parsed = JSON.parse(text); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) { + return { event: parsed.event }; + } + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) { + return { status: parsed.status }; + } + return { event: parsed }; + } catch { + return null; + } +} + +function normalizeStatus(status) { + if (!status) return null; + if (typeof status === "string") return { type: status }; + if (typeof status === "object") { + const key = Object.keys(status)[0]; + if (key && typeof status[key] === "object") return { type: key, ...status[key] }; + return status; + } + return null; +} + +function wrapZedCompletionStream(response, provider, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const state = initProviderState(provider, model); + let buffer = ""; + let done = false; + + const finish = (controller) => { + if (done) return; + const finalChunk = convertProviderEvent(provider, null, state); + enqueueSseObject(controller, encoder, finalChunk); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + done = true; + }; + + const processLine = (line, controller) => { + if (done) return; + const payload = unwrapZedLine(line); + if (!payload) return; + if (payload.done) { + finish(controller); + return; + } + if (payload.status) { + const status = normalizeStatus(payload.status); + if (status?.type === "failed" || status?.failed) { + const failed = status.failed || status; + const message = String(failed.message || failed.error || failed.code || "request failed"); + enqueueSseObject(controller, encoder, createErrorChunk(model, message)); + finish(controller); + } else if (status?.type === "stream_ended" || status === "stream_ended") { + finish(controller); + } + return; + } + const converted = convertProviderEvent(provider, payload.event, state); + enqueueSseObject(controller, encoder, converted); + }; + + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + buffer += decoder.decode(); + if (buffer) { + processLine(buffer, controller); + buffer = ""; + } + finish(controller); + }, + }), + ); + + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +class ZedExecutor extends BaseExecutor { + constructor() { + super("zed"); + } + + async resolveModel(model, credentials, signal, log) { + try { + const catalog = await resolveZedModels(credentials, { config: this.config, signal }); + let raw = catalog?.rawById?.get(model) ?? null; + if (!raw) { + const refreshed = await resolveZedModels(credentials, { + config: this.config, + signal, + forceRefresh: true, + }); + raw = refreshed?.rawById?.get(model) ?? null; + } + return { raw, provider: normalizeZedProvider(raw?.provider, model) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`); + return { raw: null, provider: normalizeZedProvider(null, model) }; + } + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const { provider } = await this.resolveModel(model, credentials, signal, log); + const providerRequest = buildProviderRequest(provider, model, body, stream, credentials); + const bodyRecord = body || {}; + const payload = { + thread_id: bodyRecord.thread_id || credentials?._clientSessionId, + prompt_id: bodyRecord.prompt_id, + provider, + model, + provider_request: providerRequest, + }; + + const response = await zedLlmFetch(credentials, "/completions", { + config: this.config, + signal, + fetchOptions: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson, text/event-stream, */*", + "User-Agent": "9router/zed", + "x-zed-version": this.config?.appVersion?.toString() || "0.200.0", + [ZED_HEADERS.clientSupportsStatus]: "true", + [ZED_HEADERS.clientSupportsStreamEnded]: "true", + }, + body: JSON.stringify(payload), + }, + }); + + const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response; + return { + response: wrapped, + url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`, + headers: { "Content-Type": "application/json", Authorization: "Bearer " }, + transformedBody: payload, + }; + } + + parseError(response, bodyText) { + let parsed = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + + const errorObj = parsed?.error || undefined; + const code = parsed?.code || errorObj?.code || ""; + const rawMessage = + parsed?.message || errorObj?.message || bodyText || response.statusText; + if (code === "trial_blocked") { + return { + status: response.status, + message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`, + }; + } + if (code) { + return { status: response.status, message: `Zed ${code}: ${rawMessage}` }; + } + return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` }; + } + + async refreshCredentials() { + // Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh. + return null; + } + + needsRefresh() { + return false; + } +} + +export default ZedExecutor; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index b5cf8a84..cb6ce961 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,18 +1,19 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; +import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; -import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; -import { COLORS } from "../utils/stream.js"; +import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js"; import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; -import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; +import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { PROVIDERS } from "../config/providers.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; -import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { getExecutor } from "../executors/index.js"; +import { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; @@ -23,9 +24,11 @@ import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js"; +import { compressWithPxpipe } from "../rtk/pxpipe.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; /** * Core chat handler - shared between SSE and Worker @@ -34,9 +37,38 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) { +/** + * Remove translator-internal continuity fields from the outbound upstream + * body. The Responses→Chat request translator stashes reasoning + * `encrypted_content` on assistant messages so a later openai→responses + * round-trip can restore the store=false continuity blob; that stash must + * never reach an upstream provider. Chat-native proxies reject the unknown + * assistant-message field and answer every turn with a literal "400" body + * (observed with multi-turn Codex sessions via OpenAI-compatible nodes). + */ +export function stripContinuityFields(body) { + if (!body || !Array.isArray(body.messages)) return body; + for (const msg of body.messages) { + if (msg && typeof msg === "object") { + delete msg.encrypted_content; + delete msg.reasoning_encrypted_content; + } + } + return body; +} + +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); + // Stable per-session color so all lines of one CLI conversation share a tag + const sessionSeed = (() => { + try { + return resolveSessionId({ headers: clientRawRequest?.headers, body, connectionId, scope: provider }); + } catch { + return connectionId || ""; + } + })(); + const reqTag = log?.tagForSession ? log.tagForSession(sessionSeed) : (log?.nextTag ? log.nextTag() : ""); const sourceFormat = sourceFormatOverride || detectFormat(body); @@ -46,10 +78,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, model); - // Multi-endpoint providers: pick transport matching sourceFormat → zero translation + // Multi-endpoint providers: pick transport matching sourceFormat → zero translation. + // Per-model guard: only use the transport when the model declares support for that + // sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do + // /chat/completions), so without this guard a claude-format request would wrongly + // route kimi to /messages. + const modelSupportedFormats = getModelSupportedFormats(alias, model); const runtimeTransport = resolveTransport(provider, sourceFormat); - const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider); - if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport; + // Per-model guard: when a model declares supportedFormats, only use the + // sourceFormat-matched transport if that format is declared (opencode-go models + // differ — kimi/glm only do /chat/completions). Undeclared models keep the + // upstream default (use the transport), preserving behavior for glm/deepseek/... + const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null; + const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials); + if (useTransport && credentials) credentials.runtimeTransport = useTransport; const stripList = getModelStrip(alias, model); const upstreamModel = getModelUpstreamId(alias, model); @@ -121,11 +163,24 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred let translatedBody; let toolNameMap; + let customToolNames; if (passthrough) { log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); - translatedBody = { ...body, model: upstreamModel }; + translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; + if (provider === "codex") { + const suffixThinking = {}; + applyThinking(sourceFormat, upstreamModel, suffixThinking, provider); + if (suffixThinking.reasoning_effort) { + const reasoning = translatedBody.reasoning; + translatedBody.reasoning = { + ...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}), + effort: suffixThinking.reasoning_effort, + }; + delete translatedBody.reasoning_effort; + } + } // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects - if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel); + if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model); } else { translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); if (!translatedBody) { @@ -134,7 +189,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } toolNameMap = translatedBody._toolNameMap; delete translatedBody._toolNameMap; - translatedBody.model = upstreamModel; + customToolNames = translatedBody._customToolNames; + delete translatedBody._customToolNames; + translatedBody.model = stripThinkingSuffix(upstreamModel); + stripContinuityFields(translatedBody); } // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). @@ -150,41 +208,87 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Covers both passthrough (source shape) and translated (target shape) flows const finalFormat = passthrough ? sourceFormat : targetFormat; + // Request line: one correlated summary (fmt + thinking + counts + account) + if (log?.line) { + const clientModel = clientRawRequest?.body?.model || `${provider}/${model}`; + const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0; + const toolN = translatedBody.tools?.length || body.tools?.length || 0; + const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}→${targetFormat}`; + const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model); + const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null; + const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-"; + const parts = [ + `POST ${clientModel} → ${provider}/${model}`, + fmtStr, + stream ? "STREAM" : "JSON", + `${msgN} MSG`, + ]; + if (toolN) parts.push(`${toolN} TOOL`); + if (think) parts.push(`THINK:${think}`); + parts.push(`ACC:${acc}`); + log.line(reqTag, "▶", parts.join(" · ")); + } + // TTS models don't support tool messages/function calling if (getModelType(alias, model) === "tts" && translatedBody.messages) { translatedBody.messages = translatedBody.messages.filter(msg => msg.role !== "tool"); delete translatedBody.tools; } + // Per-request opt-out: client can bypass all token savers via header + const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off"; + // RTK: compress tool_result content - const rtkStats = compressMessages(translatedBody, rtkEnabled); + const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled); const rtkLine = formatRtkLog(rtkStats); if (rtkLine) console.log(rtkLine); // Headroom: optional external proxy compression; fail open if proxy is absent. const headroomDiagnostics = {}; - const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics }); + const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics }); const headroomLine = formatHeadroomLog(headroomStats); const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics); if (headroomLine) { log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`); if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) { - log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${headroomSizeLine}`); + log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`); } - } else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); + } else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); + + // Token-saver flags accumulator for the single "⚙" log line below. + const xf = []; // Caveman: inject terse-style system prompt - if (cavemanEnabled && cavemanLevel) { + if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { injectCaveman(translatedBody, finalFormat, cavemanLevel); - log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`); + xf.push(`CAVEMAN:${cavemanLevel}`); } // Ponytail: inject lazy-senior-dev system prompt - if (ponytailEnabled && ponytailLevel) { + if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) { injectPonytail(translatedBody, finalFormat, ponytailLevel); - log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`); + xf.push(`PONYTAIL:${ponytailLevel}`); } + // PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch + let pxpipeSummary = null; + if (pxpipeEnabled) { + const pxpipeResult = await compressWithPxpipe(translatedBody, { + enabled: true, format: finalFormat, model: upstreamModel, + minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform, + }); + pxpipeSummary = pxpipeResult.summary; + if (pxpipeResult.body) translatedBody = pxpipeResult.body; + if (pxpipeSummary?.applied) xf.push(`PXPIPE:${pxpipeSummary.imageCount}img`); + try { onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); } catch { /* stats must not break requests */ } + } + + if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · ")); + + // Pin cache breakpoints to the final body — every saver above can reshape + // system/tools/messages, and a stale anchor costs a full prefix rewrite. + if (passthrough && clientTool === "claude") anchorClaudeCache(translatedBody); + const executor = getExecutor(provider); trackPendingRequest(model, provider, connectionId, true); appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); @@ -198,7 +302,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (onDisconnect) onDisconnect(reason); }, onError: () => trackPendingRequest(model, provider, connectionId, false), - log, provider, model + log, provider, model, reqTag }); const proxyOptions = { @@ -236,12 +340,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Execute request let providerResponse, providerUrl, providerHeaders, finalBody; + // Most executors return their registry format. Cursor AgentService is an + // exception: it is decoded by the executor into OpenAI-compatible output. + let providerResponseFormat = targetFormat; try { const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; finalBody = result.transformedBody; + providerResponseFormat = result.responseFormat || targetFormat; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); @@ -253,6 +361,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred request: extractRequestConfig(body, stream), providerRequest: translatedBody || null, response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, + pxpipe: pxpipeSummary, status: "error" })).catch(() => { }); @@ -261,23 +370,40 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred return createErrorResult(499, "Request aborted"); } const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + if (log?.errorLine) { + log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`); + } return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); } // Handle 401/403 - try token refresh (skip for noAuth providers) if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { try { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → + // invalid_grant → auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); if (newCredentials?.accessToken || newCredentials?.copilotToken) { - log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); + if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); Object.assign(credentials, newCredentials); if (onCredentialsRefreshed) { try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); } } try { const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); - if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } + if (retryResult.response.ok) { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerResponseFormat = retryResult.responseFormat || targetFormat; + } } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } } else { log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); @@ -299,35 +425,39 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred request: extractRequestConfig(body, stream), providerRequest: finalBody || translatedBody || null, response: { error: message, status: statusCode, thinking: null }, + pxpipe: pxpipeSummary, status: "error" })).catch(() => { }); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); - console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + if (log?.errorLine) { + const urlStr = providerUrl ? `\n URL: ${providerUrl}` : ""; + log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`); + } reqLogger.logError(new Error(message), finalBody || translatedBody); return createErrorResult(statusCode, errMsg, resetsAtMs); } - const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess }; + const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log }; const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); const trackDone = () => trackPendingRequest(model, provider, connectionId, false); // Provider forced streaming but client wants JSON if (!clientRequestedStreaming && providerRequiresStreaming) { - const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog }); + const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog }); if (result) { streamController.handleComplete(); return result; } } // True non-streaming response if (!stream) { - const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog }); + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog }); streamController.handleComplete(); return result; } // Streaming response const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 3996f94f..becc0842 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -6,9 +6,10 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; -import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js"; +import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; +import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; function parseToolArguments(value) { if (!value) return {}; @@ -60,11 +61,93 @@ function openAICompletionToClaudeMessage(responseBody) { }; } +/** + * Convert an OpenAI Chat Completions non-streaming response body into the + * OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex) + * is routed to a Chat Completions upstream and `stream:false` — the streaming + * path already emits Responses events, but the JSON path returned a raw + * `chat.completion` body, so tool_calls were invisible to Responses clients. + */ +function extractCustomToolInput(argumentsValue) { + const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {}); + try { + const parsed = JSON.parse(argumentsText); + if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input; + } catch { /* raw freeform input */ } + return argumentsText; +} + +function openAICompletionToResponses(responseBody, customToolNames = null) { + const choice = responseBody?.choices?.[0]; + if (!choice) return responseBody; + + const message = choice.message || {}; + const output = []; + + // Reasoning → a reasoning item (summary text), mirroring the streaming path. + const reasoning = message.reasoning_content || message.reasoning; + if (typeof reasoning === "string" && reasoning.length > 0) { + output.push({ + type: RESPONSES_ITEM.REASONING, + summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }], + }); + } + + // Assistant text → a message item with output_text content. + const text = typeof message.content === "string" ? message.content : ""; + if (text.length > 0) { + output.push({ + type: RESPONSES_ITEM.MESSAGE, + role: ROLE.ASSISTANT, + content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }], + }); + } + + // tool_calls → function_call/custom_tool_call items (Responses-native tool shape). + for (const tc of message.tool_calls || []) { + const fn = tc.function || {}; + const custom = customToolNames?.has(fn.name); + output.push({ + type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, + id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`, + call_id: tc.id || "", + name: fn.name || "", + ...(custom + ? { input: extractCustomToolInput(fn.arguments) } + : { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }), + }); + } + + const usage = responseBody.usage || {}; + const status = choice.finish_reason === "tool_calls" ? "completed" : (choice.finish_reason === "stop" ? "completed" : (choice.finish_reason || "completed")); + + return { + id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"), + object: "response", + created_at: responseBody.created || Math.floor(Date.now() / 1000), + model: responseBody.model || "unknown", + status, + background: false, + error: null, + output, + usage: { + input_tokens: usage.prompt_tokens || usage.input_tokens || 0, + output_tokens: usage.completion_tokens || usage.output_tokens || 0, + total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), + }, + }; +} + /** * Translate non-streaming response body from provider format → OpenAI format. */ -export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) { +export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames = null) { if (targetFormat === sourceFormat) return responseBody; + // Provider responded in OpenAI Chat Completions shape but the client speaks + // Responses API — convert so tool_calls/text surface as Responses `output`. + if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.OPENAI_RESPONSES) { + return openAICompletionToResponses(responseBody, customToolNames); + } if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) { return openAICompletionToClaudeMessage(responseBody); } @@ -198,7 +281,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source /** * Handle non-streaming response from provider. */ -export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) { +export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, trackDone, appendLog, pxpipe, reqTag, log }) { trackDone(); const contentType = providerResponse.headers.get("content-type") || ""; let responseBody; @@ -235,12 +318,16 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m const usage = extractUsageFromResponse(responseBody); appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); + if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); const translatedResponse = needsTranslation(targetFormat, sourceFormat) - ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) + ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames) : responseBody; const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message"; + // Responses-format translation produces a `object:"response"` body with no + // `choices`; skip the Chat-Completions-specific post-processing below for it. + const isResponsesResponse = sourceFormat === FORMATS.OPENAI_RESPONSES && translatedResponse?.object === "response"; // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other") if (translatedResponse?.choices?.[0]) { @@ -253,13 +340,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m } // Ensure OpenAI-required fields - if (!isClaudeMessageResponse) { + if (!isClaudeMessageResponse && !isResponsesResponse) { if (!translatedResponse.object) translatedResponse.object = "chat.completion"; if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); } // Strip Azure-specific fields - if (!isClaudeMessageResponse) { + if (!isClaudeMessageResponse && !isResponsesResponse) { delete translatedResponse.prompt_filter_results; if (translatedResponse?.choices) { for (const choice of translatedResponse.choices) delete choice.content_filter_results; @@ -273,7 +360,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m // Strip reasoning_content only when content is non-empty. // When content is empty (e.g. thinking models that used all tokens for reasoning), // reasoning_content is the only useful output and must be preserved. - if (!isClaudeMessageResponse && translatedResponse?.choices) { + if (!isClaudeMessageResponse && !isResponsesResponse && translatedResponse?.choices) { for (const choice of translatedResponse.choices) { if (choice?.message?.reasoning_content && choice.message.content) { delete choice.message.reasoning_content; @@ -296,6 +383,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null, finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown" }, + pxpipe, status: "success" }, { endpoint: clientRawRequest?.endpoint || null })).catch(err => { console.error("[RequestDetail] Failed to save:", err.message); diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index aef2c5be..376d5f90 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) { }; } - // Gemini format - if (responseBody.usageMetadata) { + // Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }. + const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata; + if (usageMetadata) { return { - prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, - cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0 + prompt_tokens: usageMetadata.promptTokenCount || 0, + completion_tokens: usageMetadata.candidatesTokenCount || 0, + cached_tokens: usageMetadata.cachedContentTokenCount || 0, + reasoning_tokens: usageMetadata.thoughtsTokenCount || 0 }; } @@ -69,12 +70,31 @@ export function buildRequestDetail(base, overrides = {}) { providerRequest: base.providerRequest || null, providerResponse: base.providerResponse || null, response: base.response || {}, + pxpipe: base.pxpipe || undefined, status: base.status || "success", ...overrides }; } -export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) { +// Build the "done" summary: duration, ttft, in/out tokens with cache breakdown +export function formatDoneLine({ usage, latency }) { + const u = usage || {}; + const inTok = u.prompt_tokens ?? u.input_tokens ?? 0; + const outTok = u.completion_tokens ?? u.output_tokens ?? 0; + const cacheRead = u.cache_read_input_tokens ?? u.cached_tokens ?? u.prompt_tokens_details?.cached_tokens ?? 0; + const cacheCreate = u.cache_creation_input_tokens ?? 0; + let inStr = `IN ${inTok}`; + if (cacheRead || cacheCreate) { + const parts = []; + if (cacheRead) parts.push(`↻${cacheRead}`); + if (cacheCreate) parts.push(`+${cacheCreate}`); + inStr += ` (CACHE ${parts.join(" ")})`; + } + const ttftStr = latency?.ttft ? ` · TTFT ${latency.ttft}ms` : ""; + return `DONE ${latency?.total ?? 0}ms${ttftStr} · ${inStr} · OUT ${outTok}`; +} + +export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE", silent = false }) { if (!tokens || typeof tokens !== "object") return; const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0; @@ -82,35 +102,17 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, if (inTokens === 0 && outTokens === 0) return; - // Extract cache/reasoning tokens (unified from different formats) - const cacheRead = tokens.cache_read_input_tokens || tokens.cached_tokens || tokens.prompt_tokens_details?.cached_tokens || 0; - const cacheCreation = tokens.cache_creation_input_tokens || 0; - const reasoning = tokens.reasoning_tokens || 0; + if (!silent) { + const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""; + console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`); + } - const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""; - - let msg = `${COLORS.green}[${time}] 📊 [${label}] ${provider?.toUpperCase() || "UNKNOWN"} | in=${inTokens} | out=${outTokens}${accountSuffix}`; - if (tokens.estimated) msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`; - if (cacheRead) msg += ` | cache_read=${cacheRead}`; - if (cacheCreation) msg += ` | cache_create=${cacheCreation}`; - if (reasoning) msg += ` | reasoning=${reasoning}`; - msg += `${COLORS.reset}`; - console.log(msg); - -<<<<<<< HEAD - // Normalize to OpenAI token shape for storage (include all token types) - const normalized = { -======= // Canonicalize to one storage convention (prompt_tokens cache-inclusive) so // cached/cache-creation tokens survive to cost calc + stats. See canonicalizeUsage. const normalized = canonicalizeUsage(tokens) || { ->>>>>>> 7f436e2792be4fa5a4d1c4d6b8e9bc85eaaa6a3d prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0, - completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - reasoning_tokens: reasoning, + completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0 }; saveRequestUsage({ diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 1e0edeba..6801be89 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -3,7 +3,8 @@ import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { FORMATS } from "../../translator/formats.js"; import { PROVIDERS } from "../../config/providers.js"; -import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; +import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; +import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; // Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES; @@ -34,21 +35,97 @@ function pickAssistantMessageForChatCompletion(output) { return { msgItem: last, textContent: textFromResponsesMessageItem(last) }; } +/** + * Convert an OpenAI Chat Completions JSON body into the Responses API shape. + * Inlined here (not imported from nonStreamingHandler.js) to avoid a circular + * import. Mirrors openAICompletionToResponses in nonStreamingHandler.js. + */ +function extractCustomToolInput(argumentsValue) { + const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {}); + try { + const parsed = JSON.parse(argumentsText); + if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input; + } catch { /* raw freeform input */ } + return argumentsText; +} + +function chatCompletionToResponses(responseBody, customToolNames = null) { + const choice = responseBody?.choices?.[0]; + if (!choice) return responseBody; + + const message = choice.message || {}; + const output = []; + + const reasoning = message.reasoning_content || message.reasoning; + if (typeof reasoning === "string" && reasoning.length > 0) { + output.push({ + type: RESPONSES_ITEM.REASONING, + summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }], + }); + } + + const text = typeof message.content === "string" ? message.content : ""; + if (text.length > 0) { + output.push({ + type: RESPONSES_ITEM.MESSAGE, + role: ROLE.ASSISTANT, + content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }], + }); + } + + for (const tc of message.tool_calls || []) { + const fn = tc.function || {}; + const custom = customToolNames?.has(fn.name); + output.push({ + type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, + id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`, + call_id: tc.id || "", + name: fn.name || "", + ...(custom + ? { input: extractCustomToolInput(fn.arguments) } + : { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }), + }); + } + + const usage = responseBody.usage || {}; + return { + id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"), + object: "response", + created_at: responseBody.created || Math.floor(Date.now() / 1000), + model: responseBody.model || "unknown", + status: "completed", + background: false, + error: null, + output, + usage: { + input_tokens: usage.prompt_tokens || usage.input_tokens || 0, + output_tokens: usage.completion_tokens || usage.output_tokens || 0, + total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0), + }, + }; +} + /** * Parse OpenAI-style SSE text into a single chat completion JSON. * Used when provider forces streaming but client wants non-streaming. */ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const chunks = []; + let streamError = null; for (const line of String(rawSSE || "").split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; - try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ } + try { + const chunk = JSON.parse(payload); + if (chunk?.error) streamError = chunk.error; + else chunks.push(chunk); + } catch { /* ignore malformed lines */ } } + if (streamError) return { error: streamError }; if (chunks.length === 0) return null; const first = chunks[0]; @@ -102,7 +179,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { * Handle case: provider forced streaming but client wants JSON. * Supports both Codex/Responses API SSE and standard Chat Completions SSE. */ -export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) { +export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) { const contentType = providerResponse.headers.get("content-type") || ""; const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider)); if (!isSSE) return null; // not handled here @@ -116,7 +193,10 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr }; // Codex/Responses API SSE path - const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; + // Branch on the UPSTREAM format (targetFormat = format we spoke to the provider in), + // not the client format: a Responses-API client behind a chat-native forced-streaming + // provider still receives chat SSE chunks, which must go through the standard path. + const isCodexResponsesApi = isResponsesProvider(provider) || targetFormat === FORMATS.OPENAI_RESPONSES; if (isCodexResponsesApi) { try { const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); @@ -124,15 +204,21 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const usage = jsonResponse.usage || {}; appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); + if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); + // Same cache-inclusive total for the recorded detail, so the DB and the + // client-facing usage can never disagree. + const inTokensForLog = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens || usage.cached_tokens || 0) + + (usage.cache_creation_input_tokens || 0); const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output); const totalLatency = Date.now() - requestStartTime; saveRequestDetail(buildRequestDetail({ ...ctx, latency: { ttft: totalLatency, total: totalLatency }, - tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }, + tokens: { prompt_tokens: inTokensForLog, completion_tokens: usage.output_tokens || 0 }, response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" }, status: "success" }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); @@ -142,9 +228,21 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; } - // Build client-format response - const inTokens = usage.input_tokens || 0; + // Build client-format response. + // input_tokens EXCLUDES cached tokens on cache-capable upstreams, so summing + // only input+output under-reports prompt_tokens — measured: 2012 reported + // where the real prompt was ~5344 with 5332 served from cache. Fold the cache + // counters in, and keep them visible in prompt_tokens_details so a client can + // tell a cache hit from a small prompt. + const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || 0; + const cacheCreate = usage.cache_creation_input_tokens || 0; + const inTokens = (usage.input_tokens || 0) + cacheRead + cacheCreate; const outTokens = usage.output_tokens || 0; + const cacheDetails = (cacheRead > 0 || cacheCreate > 0) + ? { prompt_tokens_details: { + ...(cacheRead > 0 ? { cached_tokens: cacheRead } : {}), + ...(cacheCreate > 0 ? { cache_creation_tokens: cacheCreate } : {}) } } + : {}; let finalResp; // Extract tool calls from Responses API output (function_call items) @@ -179,7 +277,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr created: jsonResponse.created_at || Math.floor(Date.now() / 1000), model: jsonResponse.model || model, choices: [{ index: 0, message, finish_reason: finishReason }], - usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } + usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens, ...cacheDetails } }; } @@ -195,12 +293,19 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const sseText = await providerResponse.text(); const parsed = parseSSEToOpenAIResponse(sseText, model); if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + if (parsed.error) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + parsed.error.message || "Upstream SSE stream failed" + ); + } if (onRequestSuccess) await onRequestSuccess(); const usage = parsed.usage || {}; appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); + if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); const totalLatency = Date.now() - requestStartTime; saveRequestDetail(buildRequestDetail({ @@ -215,6 +320,15 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr status: "success" }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); + // Re-attach usage explicitly. This handler already HAS the correct usage — it is + // the same object written to the usage DB, and for a cached Claude request that DB + // row reads cache_read_input_tokens: 11022 — yet the client was observed receiving + // no usage field at all (verified 2026-08-04 with a fingerprinted payload matched + // on both sides). Whatever drops it between assembly and serialisation, the client + // must not be left unable to account for its own token spend: a caller cannot tell + // a 90%-cached request from a cheap one without this. + if (usage && Object.keys(usage).length > 0) parsed.usage = usage; + // Strip reasoning_content only when content is non-empty. // When content is empty (e.g. thinking models that used all tokens for reasoning), // reasoning_content is the only useful output and must be preserved. @@ -227,7 +341,17 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr } } - return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + // A Responses-format client (e.g. Codex) forced this provider to stream, + // but wants JSON back. parseSSEToOpenAIResponse yields a Chat Completions + // body; convert it to the Responses `output` shape so tool_calls are not + // lost on the non-streaming return path. Inlined (not imported from + // nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler + // already imports parseSSEToOpenAIResponse from this module. + const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES + ? chatCompletionToResponses(parsed, customToolNames) + : parsed; + + return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; } catch (err) { console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index f42a1369..be008e7d 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js"; import { PROVIDERS } from "../../config/providers.js"; import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; -import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js"; +import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; @@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = { /** * Determine which SSE transform stream to use based on provider/format. */ -function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { +function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; @@ -30,11 +30,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, if (needsCodexTranslation) { const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; - return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames); } if (needsTranslation(targetFormat, sourceFormat)) { - return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames); } return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); @@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) { +export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) { if (onRequestSuccess) { Promise.resolve() .then(onRequestSuccess) @@ -67,7 +67,8 @@ export async function handleStreamingResponse({ providerResponse, provider, mode const shortMsg = sanitizedTitle || (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`); const status = providerResponse.status || 502; - console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); + if (log?.errorLine) log.errorLine(reqTag, "✗", `BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`); + else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); return { success: false, @@ -78,7 +79,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode }; } - const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }); // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; @@ -94,6 +95,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode providerRequest: finalBody || translatedBody || null, providerResponse: "[Streaming - raw response not captured]", response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, + pxpipe, status: "success" }, { id: streamDetailId })).catch(err => { console.error("[RequestDetail] Failed to save streaming request:", err.message); @@ -108,7 +110,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode /** * Build onStreamComplete callback for streaming usage tracking. */ -export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) { +export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log }) { const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const onStreamComplete = (contentObj, usage, ttftAt) => { @@ -127,10 +129,15 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r providerRequest: finalBody || translatedBody || null, providerResponse: safeContent, response: { content: safeContent, thinking: safeThinking, type: "streaming" }, + pxpipe, status: "success" }, { id: streamDetailId })).catch(err => { console.error("[RequestDetail] Failed to update streaming content:", err.message); }); + + // Persist stream usage to DB (no console line; the "📊 done" line below is authoritative) + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE", silent: true }); + if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency })); }; return { onStreamComplete, streamDetailId }; diff --git a/open-sse/handlers/embeddingProviders/index.js b/open-sse/handlers/embeddingProviders/index.js index 62e52778..9dd6a604 100644 --- a/open-sse/handlers/embeddingProviders/index.js +++ b/open-sse/handlers/embeddingProviders/index.js @@ -2,6 +2,7 @@ import createOpenAIEmbeddingAdapter from "./openai.js"; import gemini from "./gemini.js"; import openaiCompatNode from "./openaiCompatNode.js"; +import selfhostedEmbedding from "./selfhostedEmbedding.js"; const OPENAI_COMPAT_PROVIDERS = [ "openai", "openrouter", "mistral", "voyage-ai", "fireworks", @@ -13,6 +14,12 @@ const ADAPTERS = { ...Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map((id) => [id, createOpenAIEmbeddingAdapter(id)])), gemini, google_ai_studio: gemini, + // Self-hosted reads creds.providerSpecificData.baseUrl (one provider, many + // servers) — but via its OWN adapter, not openaiCompatNode: that one falls back + // to api.openai.com when no baseUrl is set, which under a provider called + // "Self-hosted Embedding" means silently shipping the input and API key to + // OpenAI. selfhostedEmbedding refuses instead. + "selfhosted-embedding": selfhostedEmbedding, }; export function getEmbeddingAdapter(provider) { diff --git a/open-sse/handlers/embeddingProviders/selfhostedEmbedding.js b/open-sse/handlers/embeddingProviders/selfhostedEmbedding.js new file mode 100644 index 00000000..aa030439 --- /dev/null +++ b/open-sse/handlers/embeddingProviders/selfhostedEmbedding.js @@ -0,0 +1,46 @@ +// Self-hosted embeddings — like openaiCompatNode, but the baseUrl is REQUIRED. +// +// openaiCompatNode falls back to https://api.openai.com/v1 when a connection +// carries no providerSpecificData.baseUrl. For a custom NODE that default is +// defensible: the node was created by pointing at some OpenAI-compatible URL, and +// OpenAI is the archetype. For a provider whose entire purpose is "my own +// server", it is actively harmful — a connection saved without a baseUrl sends +// the INPUT TEXT and the API KEY to OpenAI, silently, under a provider named +// "Self-hosted Embedding". +// +// Observed exactly that with a placeholder connection (2026-08-04): +// +// [selfhosted-embedding/embedding] [401]: Incorrect API key provided: abc. +// You can find your API key at https://platform.openai.com/account/api-keys. +// +// The key "abc" was typed as a throwaway for a LOCAL server and left the network. +// A self-hosted provider must never have a cloud fallback, so this one refuses +// instead: no baseUrl means a configuration error, reported as such. +import createOpenAIEmbeddingAdapter from "./openai.js"; + +const baseAdapter = createOpenAIEmbeddingAdapter("openai"); + +export class MissingBaseUrlError extends Error { + constructor() { + super( + "Self-hosted Embedding needs an endpoint: set this connection's baseUrl to " + + "the OpenAI base URL of your server, e.g. http://host:8080/v1 (note the /v1 — " + + "\"/embeddings\" is appended to it). Refusing to fall back to api.openai.com, " + + "which would send your input and API key to OpenAI." + ); + this.name = "MissingBaseUrlError"; + this.isConfigError = true; + } +} + +export default { + ...baseAdapter, + buildUrl: (_model, creds) => { + const rawBaseUrl = creds?.providerSpecificData?.baseUrl; + if (!rawBaseUrl || !String(rawBaseUrl).trim()) throw new MissingBaseUrlError(); + // Accept either the OpenAI base or a full embeddings URL, so a value pasted + // from a curl example works as well as one typed from the help text. + const baseUrl = String(rawBaseUrl).trim().replace(/\/$/, "").replace(/\/embeddings$/, ""); + return `${baseUrl}/embeddings`; + }, +}; diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 5a4c92ba..1ee0f4cd 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -1,5 +1,5 @@ import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; -import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { getExecutor } from "../executors/index.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { getEmbeddingAdapter } from "./embeddingProviders/index.js"; @@ -38,13 +38,24 @@ export async function handleEmbeddingsCore({ } const ctx = { input }; - const url = adapter.buildUrl(model, credentials, ctx); - const headers = adapter.buildHeaders(credentials, ctx); - const requestBody = adapter.buildBody(model, { - input, - encoding_format: body.encoding_format || "float", - dimensions: body.dimensions, - }); + // buildUrl/buildHeaders/buildBody were called bare. An adapter that rejects a + // misconfigured connection — selfhosted-embedding throws when no baseUrl is set + // rather than silently falling back to api.openai.com — would have escaped this + // function uncaught, surfacing as a 500 or a request that never settles. A + // configuration mistake is a 400 with the reason in it. + let url, headers, requestBody; + try { + url = adapter.buildUrl(model, credentials, ctx); + headers = adapter.buildHeaders(credentials, ctx); + requestBody = adapter.buildBody(model, { + input, + encoding_format: body.encoding_format || "float", + dimensions: body.dimensions, + }); + } catch (error) { + log?.debug?.("EMBEDDINGS", `Request build failed: ${error.message}`); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}/${model}] ${error.message}`); + } log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`); @@ -54,6 +65,9 @@ export async function handleEmbeddingsCore({ method: "POST", headers, body: JSON.stringify(requestBody), + ...(typeof AbortSignal?.timeout === "function" + ? { signal: AbortSignal.timeout(FETCH_CONNECT_TIMEOUT_MS) } + : {}), }); } catch (error) { const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); @@ -116,6 +130,7 @@ export async function handleEmbeddingsCore({ return { success: true, + usage: normalized.usage || null, response: new Response(JSON.stringify(normalized), { headers: { "Content-Type": "application/json", diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js index da1c2303..187bd60e 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.js @@ -49,7 +49,10 @@ function truncate(text, max) { } function parseJinaTitle(text) { - const m = String(text || "").match(/^\s*#\s+(.+)$/m); + const source = String(text || ""); + const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi); + if (metadataTitle) return metadataTitle[1].trim(); + const m = source.match(/^\s*#\s+(.+)$/m); return m ? m[1].trim() : null; } @@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe } async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { - const target = `https://r.jina.ai/${encodeURIComponent(url)}`; const upstreamStart = Date.now(); - const r = await tryFetch(target, { - method: "GET", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + const r = await tryFetch("https://r.jina.ai/", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url }) }, timeoutMs); if (!r.ok) { diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 64f045c3..3c02828e 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -1,7 +1,6 @@ /** * Search Provider Request Builders * - * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). * Builds HTTP request `{ url, init }` for 10 search providers. * * @typedef {Object} SearchProviderConfig @@ -30,6 +29,8 @@ * @property {Record} [providerSpecificData] */ +import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js"; + // ── Helpers ───────────────────────────────────────────────────────────── /** @@ -64,12 +65,31 @@ export function getProviderSetting(params, key) { /** * Resolve base URL with optional override from providerOptions.baseUrl. + * + * The override is client-controlled and therefore SSRF-hardened: only public + * http(s) URLs are accepted (internal/private/loopback/metadata addresses are + * rejected via assertPublicUrl). The provider's own configured baseUrl is + * trusted as-is (admin-controlled). + * * @param {SearchProviderConfig} config * @param {SearchRequestParams} params * @returns {string} */ export function resolveBaseUrl(config, params) { const override = getProviderSetting(params, "baseUrl"); + if (override) { + // SSRF guard: client-supplied base URLs must be public http(s) only. + let parsed; + try { + parsed = new URL(override); + } catch { + throw new Error(`Invalid baseUrl: ${override}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`); + } + assertPublicUrl(override); + } return (override || config.baseUrl).replace(/\/+$/, ""); } diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js index a8a7841e..c5bfb3ad 100644 --- a/open-sse/handlers/search/chatSearch.js +++ b/open-sse/handlers/search/chatSearch.js @@ -273,6 +273,53 @@ const CHAT_SEARCH_CONFIG = { const tokens = data?.usage?.total_tokens || 0; return { text, citations, tokens }; } + }, + + "perplexity-agent": { + endpoint: () => searchEndpoint("perplexity-agent"), + buildBody: (query, model) => ({ + model, + input: query, + tools: [{ type: "web_search" }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const output = Array.isArray(data?.output) ? data.output : []; + let text = ""; + const citations = []; + for (const item of output) { + const parts = Array.isArray(item?.content) ? item.content : []; + for (const p of parts) { + if (typeof p?.text === "string") text += p.text; + const anns = Array.isArray(p?.annotations) ? p.annotations : []; + for (const a of anns) { + const c = normalizeCitation(a?.url ? a : a?.url_citation); + if (c) citations.push(c); + } + } + const results = Array.isArray(item?.results) ? item.results : []; + for (const r of results) { + const url = r?.url || r?.link; + if (!url) continue; + citations.push({ + url, + title: r?.title || "", + snippet: r?.snippet || "" + }); + } + } + if (!citations.length && Array.isArray(data?.citations)) { + for (const c of data.citations) { + const n = normalizeCitation(c); + if (n) citations.push(n); + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } } }; diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index da008bf3..898b271f 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -1,7 +1,6 @@ /** * Search Response Normalizers * - * Ported from OmniRoute open-sse/handlers/search.ts. * Each normalizer maps a provider-specific response into the unified SearchResult shape. */ diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.js index acb7d13b..8127782a 100644 --- a/open-sse/handlers/sttCore.js +++ b/open-sse/handlers/sttCore.js @@ -170,9 +170,17 @@ export async function handleSttCore({ provider, model, formData, credentials, st const file = formData.get("file"); if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); - const cfg = sttConfig; + let cfg = sttConfig; if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`); + // Per-connection endpoint override. Registry entries carry a fixed baseUrl, + // which is right for a named cloud service but useless for a self-hosted one + // whose address only the operator knows. Opt-in: absent unless the connection + // sets it, so cloud providers are untouched. Mirrors the custom embedding + // providers, which already resolve baseUrl the same way. + const overrideUrl = credentials?.providerSpecificData?.baseUrl; + if (overrideUrl) cfg = { ...cfg, baseUrl: String(overrideUrl).replace(/\/+$/, "") }; + const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken); if (cfg.authType !== "none" && !token) { return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`); diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index b4b69eeb..e074eb96 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -48,16 +48,16 @@ function createTtsResponse(base64Audio, format, responseFormat) { * * @returns {Promise<{success, response, status?, error?}>} */ -export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language }) { +export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language, style }) { if (!input?.trim()) { return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); } try { - // Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini) + // Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini, xiaomi-mimo) const adapter = getTtsAdapter(provider); if (adapter) { - const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language }); + const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language, style }); // Adapter may return a full {success, response} (legacy) or {base64, format} if (result.success !== undefined) return result; return createTtsResponse(result.base64, result.format, responseFormat); diff --git a/open-sse/handlers/ttsProviders/genericFormats.js b/open-sse/handlers/ttsProviders/genericFormats.js index 2f27f9ea..a5c0cda3 100644 --- a/open-sse/handlers/ttsProviders/genericFormats.js +++ b/open-sse/handlers/ttsProviders/genericFormats.js @@ -51,6 +51,25 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) { return responseToBase64(res, "wav"); } +// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary +async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + "model": modelId || "s2.1-pro-free", + }, + body: JSON.stringify({ + text, + format: "mp3", + ...(voiceId ? { reference_id: voiceId } : {}), + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + // Inworld: Basic auth, JSON { audioContent } async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) { const res = await fetch(baseUrl, { @@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = { tortoise, openai: openaiCompat, "minimax-tts": minimaxTts, + "fish-audio": fishAudio, }; diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.js index e1bb8b83..d2be03fa 100644 --- a/open-sse/handlers/ttsProviders/index.js +++ b/open-sse/handlers/ttsProviders/index.js @@ -6,6 +6,8 @@ import elevenlabs, { fetchElevenLabsVoices } from "./elevenlabs.js"; import openai from "./openai.js"; import openrouter from "./openrouter.js"; import gemini, { fetchGeminiVoices } from "./gemini.js"; +import xiaomiMimo from "./xiaomi-mimo.js"; +import selfhostedTts from "./selfhostedTts.js"; import { FORMAT_HANDLERS } from "./genericFormats.js"; import { parseModelVoice } from "./_base.js"; @@ -18,6 +20,8 @@ const SPECIAL_ADAPTERS = { openai, openrouter, gemini, + "xiaomi-mimo": xiaomiMimo, + "selfhosted-tts": selfhostedTts, }; export function getTtsAdapter(provider) { diff --git a/open-sse/handlers/ttsProviders/selfhostedTts.js b/open-sse/handlers/ttsProviders/selfhostedTts.js new file mode 100644 index 00000000..fdfdbf76 --- /dev/null +++ b/open-sse/handlers/ttsProviders/selfhostedTts.js @@ -0,0 +1,69 @@ +// Self-hosted OpenAI-compatible TTS — POST {baseUrl}/v1/audio/speech. +// +// A SPECIAL_ADAPTER rather than a genericFormats handler on purpose: the generic +// dispatcher resolves baseUrl from the static registry entry +// (`synthesizeViaConfig` reads `cfg.baseUrl`) and never looks at the connection, +// which is exactly the limitation this provider exists to lift. +import { Buffer } from "node:buffer"; + +const DEFAULT_BASE_URL = "http://localhost:8880"; +const DEFAULT_MODEL = "kokoro"; +const DEFAULT_VOICE = "af_heart"; + +export default { + async synthesize(text, model, credentials, responseFormat = "mp3") { + // Accept either providerSpecificData.baseUrl (how the custom embedding and + // STT providers carry it) or a bare credentials.baseUrl (how the OpenAI TTS + // adapter does), so a connection configured either way works. + const raw = credentials?.providerSpecificData?.baseUrl || credentials?.baseUrl || DEFAULT_BASE_URL; + // Tolerate a baseUrl given as the full endpoint or with a trailing /v1 — + // both are natural things to paste, and silently double-appending the path + // would 404 with nothing pointing at the cause. + const base = String(raw) + .replace(/\/+$/, "") + .replace(/\/v1\/audio\/speech$/, "") + .replace(/\/v1$/, ""); + + // The provider prefix is already stripped by getModelInfo, so `model` here is + // "kokoro" or "kokoro/af_heart" — NOT "selfhosted-tts/...". + // + // A bare value is the MODEL, not the voice. The OpenAI adapter reads a bare + // value as a voice, which is right for a service whose model is fixed + // ("tts-1") and whose voice varies — but wrong here, where the model is the + // variable part. Treating it as a voice sent voice="kokoro" upstream and + // Kokoro answered 400, so `selfhosted-tts/kokoro` — the obvious way to + // address this provider — was the one form that did not work (verified + // against a live Kokoro through 9router, 2026-08-03). + let ttsModel = DEFAULT_MODEL; + let voice = DEFAULT_VOICE; + if (model) { + const parts = String(model).split("/").filter(Boolean); + if (parts.length >= 2) { + ttsModel = parts[0]; + voice = parts.slice(1).join("/"); + } else if (parts.length === 1) { + ttsModel = parts[0]; + } + } + + const res = await fetch(`${base}/v1/audio/speech`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(credentials?.apiKey ? { Authorization: `Bearer ${credentials.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: ttsModel, + voice, + input: text, + response_format: responseFormat, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `Self-hosted TTS failed: ${res.status}`); + } + const buf = await res.arrayBuffer(); + return { base64: Buffer.from(buf).toString("base64"), format: responseFormat }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/xiaomi-mimo.js b/open-sse/handlers/ttsProviders/xiaomi-mimo.js new file mode 100644 index 00000000..46682689 --- /dev/null +++ b/open-sse/handlers/ttsProviders/xiaomi-mimo.js @@ -0,0 +1,65 @@ +// Xiaomi MiMo TTS — via OpenAI-compatible chat completions (non-streaming). +// Docs: https://mimo.mi.com/docs/zh-CN/quick-start/usage-guide/audio/speech-synthesis-v2.5 +// Message contract: target text in `role: assistant` content, style/voice +// instructions in `role: user` content. Voice is selected via the top-level +// `audio.voice` field (NOT embedded in the model name). +import { parseModelVoice } from "./_base.js"; + +const DEFAULT_MODEL = "mimo-v2.5-tts"; +const DEFAULT_VOICE = "mimo_default"; + +export default { + synthesize(text, model, credentials, responseFormat, { style, language } = {}) { + if (!credentials?.apiKey) throw new Error("xiaomi-mimo API key required"); + return synthesizeMiMo(text, model, credentials.apiKey, style, language); + }, +}; + +export async function synthesizeMiMo(text, model, apiKey, style, language) { + const { modelId, voiceId } = parseModelVoice(model, DEFAULT_MODEL, DEFAULT_VOICE, [DEFAULT_MODEL]); + + // Language and style are soft instructions → prepend as a role:user message. + // MiMo auto-detects the spoken language of the text; the hint only nudges it + // (e.g. "Speak in English.") and is independent of the chosen voice. + const instructions = []; + if (language) instructions.push(`Speak in ${language}.`); + if (style) instructions.push(style); + + const messages = [{ role: "assistant", content: text }]; + if (instructions.length) messages.unshift({ role: "user", content: instructions.join(" ") }); + + const res = await fetch("https://api.xiaomimimo.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: modelId, + stream: false, + messages, + audio: { + format: "wav", + voice: voiceId || DEFAULT_VOICE, + }, + }), + }); + + const rawText = await res.text(); + let data = {}; + if (rawText) { + try { data = JSON.parse(rawText); } catch { data = {}; } + } + + if (!res.ok) { + throw new Error(data?.error?.message || rawText || `MiMo TTS error (${res.status})`); + } + + const audio = data?.choices?.[0]?.message?.audio?.data; + if (!audio) throw new Error(data?.error?.message || "MiMo TTS returned no audio"); + + return { + base64: audio, + format: data?.choices?.[0]?.message?.audio?.format || "wav", + }; +} diff --git a/open-sse/handlers/videoCore.js b/open-sse/handlers/videoCore.js new file mode 100644 index 00000000..98d60157 --- /dev/null +++ b/open-sse/handlers/videoCore.js @@ -0,0 +1,166 @@ +import { createErrorResult } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { refreshTokenByProvider } from "../services/tokenRefresh.js"; +import { PROVIDER_MEDIA } from "../providers/index.js"; + +// Upstream fetch deadline for video job submission/polling (the job itself is +// async upstream — this only bounds the HTTP round-trip, not video rendering). +const VIDEO_FETCH_TIMEOUT_MS = Number(process.env.VIDEO_FETCH_TIMEOUT_MS || 120000); + +// POST /videos/* creates a billable upstream job. A network error after the +// request left the socket may still have created the job, so creation is NEVER +// auto-retried (the only re-send is the auth retry after a 401/403 refresh, +// which upstream rejects before job creation). +export const VIDEO_ACTIONS = new Set(["generations", "edits", "extensions"]); + +export function getVideoConfig(provider) { + return PROVIDER_MEDIA[provider]?.videoConfig || null; +} + +/** Strip bearer tokens / obvious secrets from text destined for clients or logs. */ +export function sanitizeSecrets(text, credentials = null) { + if (!text) return text; + let out = String(text).replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]"); + for (const key of ["accessToken", "refreshToken", "apiKey"]) { + const secret = credentials?.[key]; + if (typeof secret === "string" && secret.length >= 8) { + out = out.split(secret).join("[redacted]"); + } + } + return out; +} + +function buildUpstreamUrl(config, action, requestId) { + const base = config.baseUrl.replace(/\/$/, ""); + return requestId ? `${base}/${encodeURIComponent(requestId)}` : `${base}/${action}`; +} + +function buildHeaders({ token, contentType, idempotencyKey }) { + const headers = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + if (contentType) headers["Content-Type"] = contentType; + if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; + return headers; +} + +function combineSignals(signal, timeoutMs) { + const timeoutSignal = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : null; + if (signal && timeoutSignal && typeof AbortSignal.any === "function") { + return AbortSignal.any([signal, timeoutSignal]); + } + return signal || timeoutSignal || undefined; +} + +/** + * Transparent proxy for async video jobs (xAI Grok Imagine shape). + * + * - Forwards the raw body byte-for-byte (JSON or multipart) — no reshaping. + * - Passes upstream JSON (request_id, status, video.url, error) back verbatim. + * - 401/403 with a refresh token: refresh ONCE, retry ONCE. No other retry. + * - Upstream error text is sanitized before it reaches the client. + * + * @param {object} options + * @param {string} options.provider - Provider id (must have registry videoConfig) + * @param {"generations"|"edits"|"extensions"|null} options.action - Creation action (POST) + * @param {string|null} [options.requestId] - Poll target (GET /videos/{id}) + * @param {Buffer|string|null} [options.rawBody] - Exact body to forward + * @param {string|null} [options.contentType] - Original Content-Type header + * @param {string|null} [options.idempotencyKey] - Forwarded Idempotency-Key + * @param {object} options.credentials - { accessToken?, apiKey?, refreshToken?, authType? } + * @param {AbortSignal} [options.signal] - Client cancellation signal + * @param {number} [options.timeoutMs] + * @param {object} [options.log] + * @param {function} [options.onCredentialsRefreshed] + * @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>} + */ +export async function handleVideoProxyCore({ + provider, + action = null, + requestId = null, + rawBody = null, + contentType = null, + idempotencyKey = null, + credentials, + signal, + timeoutMs = VIDEO_FETCH_TIMEOUT_MS, + log, + onCredentialsRefreshed, +}) { + const config = getVideoConfig(provider); + if (!config) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support video generation`); + } + if (!requestId && !VIDEO_ACTIONS.has(action)) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`); + } + + const method = requestId ? "GET" : "POST"; + const url = buildUpstreamUrl(config, action, requestId); + const fetchSignal = combineSignals(signal, timeoutMs); + + const doFetch = (token) => + fetch(url, { + method, + headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }), + body: method === "POST" ? rawBody : undefined, + signal: fetchSignal, + }); + + let upstream; + try { + upstream = await doFetch(credentials?.accessToken || credentials?.apiKey); + } catch (error) { + if (error?.name === "AbortError" || error?.name === "TimeoutError") { + return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`); + } + // Never re-send a creation POST on network error — the job may already exist upstream. + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video upstream fetch failed: ${error.message}`, credentials)); + } + + // 401/403 → refresh once → retry once (OAuth accounts only; API keys can't refresh) + if ( + (upstream.status === HTTP_STATUS.UNAUTHORIZED || upstream.status === HTTP_STATUS.FORBIDDEN) && + credentials?.refreshToken + ) { + let refreshed = null; + try { + refreshed = await refreshTokenByProvider(provider, credentials, log); + } catch (error) { + log?.warn?.("TOKEN", `${provider} | video refresh error: ${sanitizeSecrets(error.message, credentials)}`); + } + if (refreshed?.accessToken) { + log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for video ${method}`); + Object.assign(credentials, refreshed); + if (onCredentialsRefreshed) await onCredentialsRefreshed(refreshed); + try { + await upstream.body?.cancel?.(); + } catch { /* noop */ } + try { + upstream = await doFetch(credentials.accessToken || credentials.apiKey); + } catch (error) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials)); + } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | video refresh failed — account needs re-auth`); + } + } + + const bodyText = await upstream.text().catch(() => ""); + + if (!upstream.ok) { + const message = sanitizeSecrets(bodyText || `HTTP ${upstream.status}`, credentials); + return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`); + } + + // Success: pass the upstream JSON through untouched (request_id / status / video.url). + return { + success: true, + response: new Response(bodyText, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("content-type") || "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; +} diff --git a/open-sse/index.js b/open-sse/index.js index b8181f0b..c0694580 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -47,7 +47,6 @@ export { refreshAccessToken, refreshClaudeOAuthToken, refreshGoogleToken, - refreshQwenToken, refreshCodexToken, refreshIflowToken, refreshGitHubToken, diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index c73a4bc8..24a03b04 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, @@ -96,8 +100,23 @@ export const MODEL_CAPABILITIES = { // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, "coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, + + // Kimi flagship + coding (platform + Kimi Code ids) — vision/video native + "kimi-k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 }, + "k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 }, + "kimi-for-coding": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, }; +const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; + +// Codex OAuth (ChatGPT backend) — per-model context window reported by upstream +// (lower than OpenAI API's 1.05M). Sol differs from Terra/Luna. #2720 +const CODEX_GPT_56_SOL_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 372000, maxOutput: 128000 }; +const CODEX_GPT_56_DEFAULT_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; + /** * Provider-specific capability overrides. Keyed by provider alias/id. */ @@ -111,6 +130,28 @@ export const PROVIDER_CAPABILITIES = { "deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, "deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, }, + "codex": { + "gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS, + "gpt-5.6-sol-review": CODEX_GPT_56_SOL_CAPS, + "gpt-5.6-terra": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-terra-review": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-luna": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-luna-review": CODEX_GPT_56_DEFAULT_CAPS, + }, + "kiro": { + "gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-luna": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-sol-thinking": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-terra-thinking": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-luna-thinking": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-sol-agentic": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-terra-agentic": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-luna-agentic": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-sol-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-terra-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES, + "gpt-5.6-luna-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES, + }, // CodeBuddy.cn — authoritative per-model metadata from the gateway's model // config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision= // supportsImages). Every model reasons via OpenAI-style reasoning_effort @@ -133,6 +174,11 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + // Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output). + "poolside": { + "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, + "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 }, + }, }; /** @@ -143,6 +189,7 @@ export const PROVIDER_CAPABILITIES = { */ export const PATTERN_CAPABILITIES = [ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── + { pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, @@ -158,6 +205,7 @@ export const PATTERN_CAPABILITIES = [ // ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─ { pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } }, + { pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } }, { pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } }, { pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } }, { pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } }, @@ -186,6 +234,8 @@ export const PATTERN_CAPABILITIES = [ // ── Grok (vision + Live Search) ────────────────────────────────── { pattern: "*grok*image*", caps: { imageOutput: true } }, { pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } }, + // Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models + { pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } }, { pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, { pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } }, { pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, @@ -204,7 +254,9 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } }, // ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ───── - { pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } }, + { pattern: "*kimi*k3*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } }, + { pattern: "*kimi*for-coding*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } }, + { pattern: "*kimi*k2.7*code*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } }, { pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } }, @@ -228,7 +280,7 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } }, // ── Xiaomi MiMo (vision, 1M / 262K ctx) ────────────────────────── - { pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } }, + { pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } }, { pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } }, { pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } }, @@ -250,6 +302,13 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } }, + // ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ── + // Free tiers cap S 2.1 well below the paid 1M window → match the free suffix + // (":free" or "-free", depending on reseller) before the plain id. + { pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + { pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } }, + { pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + // ── Others ─────────────────────────────────────────────────────── { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, @@ -269,13 +328,17 @@ export const PATTERN_CAPABILITIES = [ export function getCapabilitiesForModel(provider, model) { if (!model) return { ...DEFAULT_CAPABILITIES }; + // Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7". + const baseModel = model.includes("/") ? model.split("/").pop() : model; + // 1. Provider-specific override - if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) { - return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] }; + if (provider) { + const providerCaps = PROVIDER_CAPABILITIES[provider]; + if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] }; + if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] }; } - // 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7") - const baseModel = model.includes("/") ? model.split("/").pop() : model; + // 2. Canonical exact if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] }; if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] }; diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js index 8be351ad..73bf7139 100644 --- a/open-sse/providers/models/schema.js +++ b/open-sse/providers/models/schema.js @@ -1,5 +1,14 @@ import { deriveModelName } from "./namePatterns.js"; +// Normalize version separators in a model id: hyphen between two digits becomes a dot. +// Registry ids use dots for versions ("claude-sonnet-4.5") but clients (CLIs, aliases) +// often send them with dashes ("claude-sonnet-4-5"). Only digit-digit hyphens are +// touched, so word/suffix hyphens stay intact ("-thinking", "-agentic", "qwen3-coder-next"). +export function normalizeModelId(modelId) { + if (typeof modelId !== "string") return modelId; + return modelId.replace(/(\d)-(\d)/g, "$1.$2"); +} + // Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.) export const MODEL_DEFAULTS = { kind: "llm", @@ -29,3 +38,11 @@ export function modelStrip(model) { export function modelTargetFormat(model) { return model?.targetFormat || MODEL_DEFAULTS.targetFormat; } + +// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the +// sourceFormat-matched transport for multi-endpoint providers whose models differ +// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen +// also do /messages, deepseek also does /responses). +export function modelSupportedFormats(model) { + return model?.supportedFormats || null; +} diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 2fb0cfc9..cf6ffd55 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -28,6 +28,7 @@ export const MODEL_PRICING = { "claude-sonnet-4.6": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 }, "claude-opus-4-5-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, "claude-opus-4-6-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + "claude-fable-5": { input: 10.00, output: 50.00, cached: 1.00, reasoning: 50.00, cache_creation: 12.50 }, // === OpenAI / GPT === "gpt-3.5-turbo": { input: 0.50, output: 1.50, cached: 0.25, reasoning: 2.25, cache_creation: 0.50 }, @@ -36,27 +37,37 @@ export const MODEL_PRICING = { "gpt-4o": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 }, "gpt-4o-mini": { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 }, "gpt-4.1": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 }, - "gpt-5": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, - "gpt-5-mini": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 }, - "gpt-5-codex": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, - "gpt-5.1": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, - "gpt-5.1-codex": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, + "gpt-5": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 }, + "gpt-5-mini": { input: 0.25, output: 2.00, cached: 0.125, reasoning: 2.00, cache_creation: 0.25 }, + "gpt-5-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 }, + "gpt-5.1": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 }, + "gpt-5.1-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 }, "gpt-5.1-codex-mini": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, "gpt-5.1-codex-mini-high": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 }, "gpt-5.1-codex-max": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 }, - "gpt-5.2": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, - "gpt-5.2-codex": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, - "gpt-5.3-codex": { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 }, - "gpt-5.3-codex-xhigh": { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 }, - "gpt-5.3-codex-high": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 }, - "gpt-5.3-codex-low": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, - "gpt-5.3-codex-none": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, + "gpt-5.2": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 }, + "gpt-5.2-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 }, + "gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 }, "gpt-5.3-codex-spark": { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 }, + "gpt-5.6": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 }, + "gpt-5.6-luna": { input: 1.00, output: 6.00, cached: 0.10, reasoning: 6.00, cache_creation: 1.00 }, + "gpt-5.6-terra": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 }, + "gpt-5.6-sol": { input: 5.00, output: 30.00, cached: 0.50, reasoning: 30.00, cache_creation: 5.00 }, "o1": { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 }, "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, // === Gemini === - "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 }, + "gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, @@ -74,10 +85,18 @@ export const MODEL_PRICING = { "qwen3-coder-flash": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, // === Kimi === + // Official platform.kimi.ai: cache-hit / cache-miss / output per 1M tokens + "kimi-k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 }, + "k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 }, + "kimi-k2.7-code": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 }, + "kimi-k2.7-code-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 }, + "kimi-for-coding": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 }, + "kimi-for-coding-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 }, "kimi-k2": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, "kimi-k2-thinking": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, "kimi-k2.5": { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 }, "kimi-k2.5-thinking": { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 }, + "kimi-k2.6": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, "kimi-latest": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, // === DeepSeek === @@ -122,10 +141,126 @@ export const MODEL_PRICING = { * Keyed by provider alias (cc, cx, gc, gh, ...) or provider id (openai, anthropic, ...). */ export const PROVIDER_PRICING = { - // GitHub Copilot (gh) — gpt-5.3-codex has different rate than canonical + // GitHub Copilot (gh) — explicit override, matches canonical gpt-5.3-codex rate gh: { "gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 }, }, + // TokenRouter — exact rates from https://api.tokenrouter.com/api/pricing ($1/1M tokens). + // Ratio→USD: input = model_ratio×2, output = model_ratio×completion_ratio×2. + // These override the canonical MODEL_PRICING/PATTERN_PRICING, whose rates often + // differ from TokenRouter's reseller pricing. + tokenrouter: { + "MiniMax-M3": { input: 0.3, output: 1.2, cached: 0.06, reasoning: 1.2 }, + "anthropic/claude-fable-5": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 }, + "anthropic/claude-haiku-4.5": { input: 1.0, output: 5.0, cached: 0.1, cache_creation: 1.25, reasoning: 5.0 }, + "anthropic/claude-opus-4.5": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "anthropic/claude-opus-4.6": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "anthropic/claude-opus-4.7": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "anthropic/claude-opus-4.7-fast": { input: 30, output: 150, cached: 3.0, reasoning: 150 }, + "anthropic/claude-opus-4.8": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "anthropic/claude-opus-4.8-fast": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 }, + "anthropic/claude-opus-5": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "anthropic/claude-opus-5-fast": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 }, + "anthropic/claude-sonnet-4": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 }, + "anthropic/claude-sonnet-4.5": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 }, + "anthropic/claude-sonnet-4.6": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 }, + "anthropic/claude-sonnet-5": { input: 2, output: 10, cached: 0.2, reasoning: 10 }, + "claude-opus-4-8-m-aws": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 }, + "deepseek/deepseek-v3.2": { input: 0.26, output: 0.38, cached: 0.13, reasoning: 0.38 }, + "deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28 }, + "deepseek/deepseek-v4-flash-0731": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28 }, + "deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cached: 0.003625, reasoning: 0.87 }, + "ex/gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25, reasoning: 15.0 }, + "google/gemini-2.5-flash-image": { input: 0.3, output: 2.5, reasoning: 2.5 }, + "google/gemini-3-flash-preview": { input: 0.5, output: 3.0, cached: 0.05, cache_creation: 0.08333, reasoning: 3.0 }, + "google/gemini-3-pro-image-preview": { input: 2, output: 12, reasoning: 12 }, + "google/gemini-3.1-flash-image-preview": { input: 0.5, output: 3.0, reasoning: 3.0 }, + "google/gemini-3.1-flash-lite-image": { input: 0.25, output: 1.5, reasoning: 1.5 }, + "google/gemini-3.1-pro-preview": { input: 2, output: 12, cached: 0.2, cache_creation: 0.375, reasoning: 12 }, + "google/gemini-3.5-flash": { input: 1.5, output: 9.0, cached: 0.15, cache_creation: 0.08333, reasoning: 9.0 }, + "google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cached: 0.03, cache_creation: 0.08333, reasoning: 2.5 }, + "google/gemini-3.6-flash": { input: 1.5, output: 7.5, cached: 0.15, cache_creation: 0.08333, reasoning: 7.5 }, + "google/gemini-embedding-2": { input: 1.0, output: 6.0, cached: 0.1, reasoning: 6.0 }, + "google/gemma-4-26b-a4b-it": { input: 0.06, output: 0.33, reasoning: 0.33 }, + "kling-3.0-turbo": { input: 2.1, output: 2.1, reasoning: 2.1 }, + "microsoft/mai-image-2.5": { input: 5.0, output: 47.0, reasoning: 47.0 }, + "minimax/minimax-m2-her": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 }, + "minimax/minimax-m2.1": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 }, + "minimax/minimax-m2.1-highspeed": { input: 0.6, output: 2.4, cached: 0.06, reasoning: 2.4 }, + "minimax/minimax-m2.5": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 }, + "minimax/minimax-m2.7": { input: 0.3, output: 1.2, cached: 0.06, reasoning: 1.2 }, + "minimax/minimax-m2.7-highspeed": { input: 0.6, output: 2.4, cached: 0.06, reasoning: 2.4 }, + "miromind/mirothinker-1-7-deepresearch": { input: 4, output: 25.0, reasoning: 25.0 }, + "miromind/mirothinker-1-7-deepresearch-mini": { input: 1.25, output: 10.0, reasoning: 10.0 }, + "mistralai/devstral-2512": { input: 0.4, output: 2.0, cached: 0.04, reasoning: 2.0 }, + "mistralai/mistral-medium-3-5": { input: 1.5, output: 7.5, reasoning: 7.5 }, + "mistralai/mistral-small-2603": { input: 0.15, output: 0.6, cached: 0.015, reasoning: 0.6 }, + "mistralai/voxtral-small-24b-2507": { input: 0.1, output: 0.3, cached: 0.01, reasoning: 0.3 }, + "moonshotai/kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.1, reasoning: 3.0 }, + "moonshotai/kimi-k2.6": { input: 0.95, output: 4.0, cached: 0.16, reasoning: 4.0 }, + "moonshotai/kimi-k2.7-code": { input: 0.9286, output: 3.8571, cached: 0.1857, reasoning: 3.8571 }, + "moonshotai/kimi-k3": { input: 3.0, output: 15.0, cached: 0.3, reasoning: 15.0 }, + "nvidia/nemotron-3-super-120b-a12b": { input: 0.3, output: 0.9, cached: 0.1, reasoning: 0.9 }, + "openai/gpt-4o-mini": { input: 0.15, output: 0.6, cached: 0.075, reasoning: 0.6 }, + "openai/gpt-5": { input: 1.25, output: 10.0, cached: 0.125, reasoning: 10.0 }, + "openai/gpt-5-image": { input: 10, output: 40, cached: 2.5, reasoning: 40 }, + "openai/gpt-5-image-mini": { input: 2.5, output: 8.0, cached: 0.25, reasoning: 8.0 }, + "openai/gpt-5-mini": { input: 0.25, output: 2.0, cached: 0.025, reasoning: 2.0 }, + "openai/gpt-5.2": { input: 1.75, output: 14.0, cached: 0.175, reasoning: 14.0 }, + "openai/gpt-5.3-codex": { input: 1.75, output: 14.0, cached: 0.175, reasoning: 14.0 }, + "openai/gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25, reasoning: 15.0 }, + "openai/gpt-5.4-image-2": { input: 8, output: 30.0, cached: 2.0, reasoning: 30.0 }, + "openai/gpt-5.4-mini": { input: 0.75, output: 4.5, cached: 0.075, reasoning: 4.5 }, + "openai/gpt-5.4-nano": { input: 0.2, output: 1.25, cached: 0.02, reasoning: 1.25 }, + "openai/gpt-5.4-pro": { input: 30, output: 180, reasoning: 180 }, + "openai/gpt-5.5": { input: 5.0, output: 30.0, cached: 0.5, reasoning: 30.0 }, + "openai/gpt-5.5-pro": { input: 30, output: 180, reasoning: 180 }, + "openai/gpt-5.6-luna": { input: 0.2, output: 1.2, cached: 0.02, cache_creation: 0.25, reasoning: 1.2 }, + "openai/gpt-5.6-sol": { input: 5.0, output: 30.0, cached: 0.5, cache_creation: 6.25, reasoning: 30.0 }, + "openai/gpt-5.6-terra": { input: 2, output: 12, cached: 0.2, cache_creation: 2.5, reasoning: 12 }, + "openai/gpt-audio": { input: 2.5, output: 10.0, reasoning: 10.0 }, + "openai/gpt-audio-mini": { input: 0.6, output: 2.4, reasoning: 2.4 }, + "openai/gpt-oss-120b": { input: 0.039, output: 0.18, reasoning: 0.18 }, + "qwen/qwen3-coder-next": { input: 0.12, output: 0.75, cached: 0.06, reasoning: 0.75 }, + "qwen/qwen3.5-122b-a10b": { input: 0.26, output: 2.08, reasoning: 2.08 }, + "qwen/qwen3.5-35b-a3b": { input: 0.1625, output: 1.3, reasoning: 1.3 }, + "qwen/qwen3.5-397b-a17b": { input: 0.39, output: 2.34, reasoning: 2.34 }, + "qwen/qwen3.5-9b": { input: 0.1, output: 0.15, reasoning: 0.15 }, + "qwen/qwen3.5-flash": { input: 0.1048, output: 0.4194, reasoning: 0.4194 }, + "qwen/qwen3.5-plus-02-15": { input: 0.26, output: 1.56, reasoning: 1.56 }, + "qwen/qwen3.6-plus": { input: 0.54, output: 3.21, reasoning: 3.21 }, + "qwen/qwen3.7-max": { input: 1.25, output: 3.75, cached: 0.25, reasoning: 3.75 }, + "qwen/qwen3.7-plus": { input: 0.4, output: 1.6, cached: 0.08, reasoning: 1.6 }, + "qwen/qwen3.8-max": { input: 2, output: 6, cached: 0.25, cache_creation: 2.5, reasoning: 6 }, + "qwen3.5-omni-plus": { input: 1.0, output: 5.7143, reasoning: 5.7143 }, + "qwen3.6-flash": { input: 0.171, output: 1.029, cached: 0.017, cache_creation: 0.214, reasoning: 1.029 }, + "sakana/fugu-ultra": { input: 5.0, output: 30.0, cached: 0.5, reasoning: 30.0 }, + "seed-2-0-code-preview-260328": { input: 1.0, output: 6.0, cached: 0.2, cache_creation: 0.008333, reasoning: 6.0 }, + "seed-2-0-lite-260428": { input: 0.5, output: 4.0, cached: 0.1, cache_creation: 0.008333, reasoning: 4.0 }, + "seed-2-0-mini-260428": { input: 0.2, output: 0.8, cached: 0.04, cache_creation: 0.00833, reasoning: 0.8 }, + "seed-2-0-pro-260328": { input: 1.0, output: 6.0, cached: 0.2, cache_creation: 0.008333, reasoning: 6.0 }, + "stepfun/step-3.5-flash": { input: 0.1, output: 0.3, cached: 0.02, reasoning: 0.3 }, + "stepfun/step-3.7-flash": { input: 0.2, output: 1.15, cached: 0.04, reasoning: 1.15 }, + "tencent/hy3-preview": { input: 0.066, output: 0.26, cached: 0.029, reasoning: 0.26 }, + "x-ai/grok-4.1-fast": { input: 0.2, output: 0.5, cached: 0.05, reasoning: 0.5 }, + "x-ai/grok-4.20-beta": { input: 2, output: 6, cached: 0.2, reasoning: 6 }, + "x-ai/grok-4.3": { input: 1.25, output: 2.5, cached: 0.2, reasoning: 2.5 }, + "x-ai/grok-4.5": { input: 2, output: 6, cached: 0.5, reasoning: 6 }, + "x-ai/grok-build-0.1": { input: 1.0, output: 2.0, cached: 0.2, reasoning: 2.0 }, + "xiaomi/mimo-v2-flash": { input: 0.1, output: 0.3, cached: 0.01, reasoning: 0.3 }, + "xiaomi/mimo-v2-omni": { input: 0.4, output: 2.0, cached: 0.08, reasoning: 2.0 }, + "xiaomi/mimo-v2-pro": { input: 1.0, output: 3.0, cached: 0.2, reasoning: 3.0 }, + "xiaomi/mimo-v2.5": { input: 0.4, output: 2.0, cached: 0.08, reasoning: 2.0 }, + "xiaomi/mimo-v2.5-pro": { input: 1.0, output: 3.0, cached: 0.2, reasoning: 3.0 }, + "z-ai/glm-4.5-air": { input: 0.13, output: 0.85, cached: 0.025, reasoning: 0.85 }, + "z-ai/glm-4.6": { input: 0.6, output: 2.2, cached: 0.11, reasoning: 2.2 }, + "z-ai/glm-4.6v": { input: 0.3, output: 0.9, reasoning: 0.9 }, + "z-ai/glm-4.7": { input: 0.6, output: 2.2, cached: 0.11, reasoning: 2.2 }, + "z-ai/glm-5": { input: 1.0, output: 3.2, cached: 0.2, reasoning: 3.2 }, + "z-ai/glm-5-turbo": { input: 1.2, output: 4.0, cached: 0.24, reasoning: 4.0 }, + "z-ai/glm-5.1": { input: 1.05, output: 3.5, cached: 0.525, reasoning: 3.5 }, + "z-ai/glm-5.2": { input: 1.4, output: 4.4, cached: 0.26, reasoning: 4.4 }, + }, }; /** @@ -140,11 +275,11 @@ export const PATTERN_PRICING = [ { pattern: "*-codex-max", pricing: { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 } }, { pattern: "*-codex-mini-*", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } }, { pattern: "*-codex-mini", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } }, - { pattern: "*-codex-low", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } }, - { pattern: "*-codex-none", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "*-codex-low", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, + { pattern: "*-codex-none", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, { pattern: "*-codex-spark", pricing: { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 } }, - { pattern: "codex-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, - { pattern: "*-codex", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "codex-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, + { pattern: "*-codex", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, // --- Claude --- { pattern: "claude-opus-*", pricing: { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 } }, @@ -161,11 +296,12 @@ export const PATTERN_PRICING = [ { pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } }, // --- GPT (specific first, generic last) --- - { pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } }, - { pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } }, - { pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } }, - { pattern: "gpt-5-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, - { pattern: "gpt-5*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "gpt-5.6-*", pricing: { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 } }, + { pattern: "gpt-5.3-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, + { pattern: "gpt-5.2-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } }, + { pattern: "gpt-5.1-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } }, + { pattern: "gpt-5-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } }, + { pattern: "gpt-5*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } }, { pattern: "gpt-4o-*", pricing: { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 } }, { pattern: "gpt-4o", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } }, { pattern: "gpt-4*", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } }, @@ -183,6 +319,7 @@ export const PATTERN_PRICING = [ // --- Kimi --- { pattern: "kimi-*-thinking", pricing: { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 } }, + { pattern: "kimi-k3*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 } }, { pattern: "kimi-k2*", pricing: { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 } }, { pattern: "kimi-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js index b2eca7d8..45d4f21d 100644 --- a/open-sse/providers/registry/alicode-intl.js +++ b/open-sse/providers/registry/alicode-intl.js @@ -3,13 +3,13 @@ export default { priority: 10, alias: "alicode-intl", display: { - name: "Alibaba Intl", + name: "Alibaba Coding", icon: "cloud", color: "#FF6A00", textIcon: "ALi", - website: "https://modelstudio.console.alibabacloud.com", + website: "https://www.alibabacloud.com/product/coding", notice: { - apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + apiKeyUrl: "https://www.alibabacloud.com/product/coding", }, }, category: "apikey", diff --git a/open-sse/providers/registry/alims-intl.js b/open-sse/providers/registry/alims-intl.js new file mode 100644 index 00000000..66e045fc --- /dev/null +++ b/open-sse/providers/registry/alims-intl.js @@ -0,0 +1,32 @@ +// Model Studio Intl — standard DashScope API keys (sk-...), NOT Coding Plan keys. +// Sibling of alicode-intl (Coding Plan). Two key types use two different hosts. +export default { + id: "alims-intl", + priority: 11, + alias: "alims-intl", + display: { + name: "Alibaba Studio", + icon: "cloud", + color: "#FF6A00", + textIcon: "ALi", + website: "https://modelstudio.console.alibabacloud.com", + notice: { + apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + headers: {}, + quirks: { preserveCacheControl: true }, + }, + models: [ + { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5", name: "GLM 5" }, + { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "glm-4.7", name: "GLM 4.7" }, + ], +}; diff --git a/open-sse/providers/registry/alitp-intl.js b/open-sse/providers/registry/alitp-intl.js new file mode 100644 index 00000000..bebc4d7f --- /dev/null +++ b/open-sse/providers/registry/alitp-intl.js @@ -0,0 +1,35 @@ +// Token Plan — credit subscription keys on token-plan..maas.aliyuncs.com. +// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio +// (alims-intl) both reject these keys, and they reject Model Studio keys back. +// Singapore is the only region that serves the plan; eu-central-1 answers +// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not +// authorized for this plan, so OpenAI-compatible mode is the only transport. +export default { + id: "alitp-intl", + priority: 11, + alias: "alitp-intl", + display: { + name: "Alibaba Token Plan", + icon: "cloud", + color: "#FF6A00", + textIcon: "ATP", + website: "https://www.alibabacloud.com/campaign/ai-landing-page-token", + notice: { + apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", + headers: {}, + quirks: { preserveCacheControl: true }, + }, + models: [ + { id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max" }, + { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, + { id: "qwen3.6-flash", name: "Qwen3.6 Flash" }, + { id: "glm-5.2", name: "GLM 5.2" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + ], +}; diff --git a/open-sse/providers/registry/anthropic.js b/open-sse/providers/registry/anthropic.js index 1f6a3494..f83937fe 100644 --- a/open-sse/providers/registry/anthropic.js +++ b/open-sse/providers/registry/anthropic.js @@ -1,5 +1,3 @@ -import { CLAUDE_API_HEADERS } from "../shared.js"; - export default { id: "anthropic", priority: 30, @@ -19,7 +17,7 @@ export default { baseUrl: "https://api.anthropic.com/v1/messages", format: "claude", headers: { - "Anthropic-Version": "2023-06-01", + "anthropic-version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", }, }, diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 29003527..2666552b 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -1,5 +1,4 @@ -import { platform, arch } from "os"; -import { ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js"; +import { ANTIGRAVITY_IDE_BASE_URL, ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js"; export default { id: "antigravity", @@ -20,13 +19,10 @@ export default { category: "oauth", serviceKinds: ["llm", "image"], transport: { - baseUrls: [ - "https://daily-cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", - ], + baseUrls: [ANTIGRAVITY_IDE_BASE_URL], format: "antigravity", headers: { - "User-Agent": "antigravity/1.107.0 darwin/arm64", + "User-Agent": ANTIGRAVITY_IDE_USER_AGENT, }, retry: { "429": { @@ -40,6 +36,7 @@ export default { }, }, usage: { + // Discovery (quota/project) on PROD; daily host rejects these. quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", @@ -48,6 +45,13 @@ export default { clientSecret: "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", }, models: [ + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" }, + { id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, + { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, @@ -71,12 +75,11 @@ export default { "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ], - apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiEndpoint: "https://daily-cloudcode-pa.googleapis.com", apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", - loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1", - loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", + loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT, refreshLeadMs: 300000, }, features: { diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js new file mode 100644 index 00000000..16ed8b3e --- /dev/null +++ b/open-sse/providers/registry/api-airforce.js @@ -0,0 +1,36 @@ +export default { + id: "api-airforce", + alias: "af", + aliases: [ + "airforce", + ], + uiAlias: "af", + display: { + name: "API.airforce", + icon: "flight", + color: "#0EA5E9", + textIcon: "AF", + website: "https://api.airforce", + notice: { + apiKeyUrl: "https://api.airforce", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.airforce/v1/chat/completions", + validateUrl: "https://api.airforce/v1/models", + headers: { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + }, + models: [ + { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/baidu.js b/open-sse/providers/registry/baidu.js new file mode 100644 index 00000000..62541e7d --- /dev/null +++ b/open-sse/providers/registry/baidu.js @@ -0,0 +1,33 @@ +export default { + id: "baidu", + alias: "qianfan", + aliases: ["qianfan", "ernie", "baidu-qianfan"], + uiAlias: "qianfan", + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Baidu Qianfan", + icon: "search", + color: "#2932E1", + textIcon: "BD", + website: "https://cloud.baidu.com/product/qianfan.html", + notice: { + apiKeyUrl: + "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", + }, + }, + transport: { + baseUrl: "https://qianfan.baidubce.com/v2/chat/completions", + validateUrl: "https://qianfan.baidubce.com/v2/models", + }, + models: [ + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextLength: 1048576 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576 }, + { id: "glm-5.2", name: "GLM 5.2", contextLength: 512000 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 198000 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "qwen3.5-397b-a17b", name: "Qwen 3.5 397B A17B", contextLength: 262144 }, + { id: "qwen3.5-27b", name: "Qwen 3.5 27B", contextLength: 262144 }, + ], +}; diff --git a/open-sse/providers/registry/bazaarlink.js b/open-sse/providers/registry/bazaarlink.js new file mode 100644 index 00000000..44d4dac8 --- /dev/null +++ b/open-sse/providers/registry/bazaarlink.js @@ -0,0 +1,47 @@ +export default { + id: "bazaarlink", + alias: "bzl", + aliases: ["bazaar-link"], + uiAlias: "bzl", + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Bazaarlink", + icon: "storefront", + color: "#DC2626", + textIcon: "BZ", + website: "https://bazaarlink.ai", + notice: { apiKeyUrl: "https://bazaarlink.ai" }, + }, + transport: { + baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", + validateUrl: "https://bazaarlink.ai/api/v1/models", + }, + models: [ + { id: "auto:free", name: "Auto Free (Zero Cost)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 1000000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 }, + { id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 }, + { id: "grok-4.3", name: "Grok 4.3", contextLength: 1000000 }, + { id: "grok-4.20", name: "Grok 4.20", contextLength: 2000000 }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", contextLength: 1048576 }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", contextLength: 1048576 }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 }, + { id: "glm-5", name: "GLM 5", contextLength: 204800 }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1050000 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1050000 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m2.7", name: "MiniMax M2.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextLength: 1000000 }, + { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/bluesminds.js b/open-sse/providers/registry/bluesminds.js new file mode 100644 index 00000000..34577caf --- /dev/null +++ b/open-sse/providers/registry/bluesminds.js @@ -0,0 +1,38 @@ +export default { + id: "bluesminds", + alias: "bm", + aliases: ["blue-sminds"], + uiAlias: "bm", + hidden: true, + display: { + name: "BluesMinds", + icon: "psychology", + color: "#2563EB", + textIcon: "BM", + website: "https://bluesminds.com", + notice: { apiKeyUrl: "https://bluesminds.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.bluesminds.com/v1/chat/completions", + validateUrl: "https://api.bluesminds.com/v1/models", + }, + models: [ + { id: "gpt-4.1", name: "GPT-4.1", contextLength: 1048576 }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1048576 }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1048576 }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", contextLength: 1048576 }, + { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)", contextLength: 1048576 }, + { id: "qwen-turbo", name: "Qwen Turbo", contextLength: 1000000 }, + { id: "kimi-k2", name: "Kimi K2", contextLength: 262144 }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", contextLength: 262144 }, + { id: "glm-4.7", name: "GLM 4.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)", contextLength: 200000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index 9d483d8f..8c47a6b3 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -49,9 +49,6 @@ export default { header: "Authorization", scheme: "bearer", }, - hooks: [ - "claudeOverlay", - ], }, usage: { oauthUrl: "https://api.anthropic.com/api/oauth/usage", @@ -60,12 +57,9 @@ export default { }, }, models: [ - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" }, - { id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" }, + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-fable-5", name: "Claude Fable 5" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], oauth: { diff --git a/open-sse/providers/registry/cloudflare-ai.js b/open-sse/providers/registry/cloudflare-ai.js index 4d440b74..53f1a697 100644 --- a/open-sse/providers/registry/cloudflare-ai.js +++ b/open-sse/providers/registry/cloudflare-ai.js @@ -19,6 +19,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], hasProviderSpecificData: true, transport: { baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions", diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js new file mode 100644 index 00000000..eab1ce93 --- /dev/null +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -0,0 +1,77 @@ +// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape, +// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs +// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI). +export default { + id: "codebuddy-intl", + alias: "cbai", + uiAlias: "cbai", + hidden: false, + priority: 90, + display: { + name: "CodeBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://www.codebuddy.ai", + notice: { + signupUrl: "https://www.codebuddy.ai", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + // Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN). + baseUrl: "https://www.codebuddy.ai/v2/chat/completions", + forceStream: true, + // CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN. + thinkingFormat: "openai", + headers: { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + // Intl billing endpoint mirrors CN shape (data.Response.Data.Accounts[]). + usage: { + url: "https://www.codebuddy.ai/v2/billing/meter/get-user-resource", + }, + }, + // Same model lineup exposed by the CN gateway — intl backend is the same catalog. + models: [ + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "glm-5.1", name: "GLM-5.1" }, + { id: "glm-5.0", name: "GLM-5.0" }, + { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, + { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, + { id: "glm-4.7", name: "GLM-4.7" }, + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "hy3-preview", name: "Hy3 Preview" }, + { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, + ], + oauth: { + baseUrl: "https://www.codebuddy.ai", + stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state", + tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token", + refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh", + userAgent: "IDE/2.63.2 CodeBuddy/2.63.2", + platform: "ide", + pollInterval: 5000, + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 0d2ddc05..6fc7501d 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -45,22 +45,18 @@ export default { }, }, models: [ + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, + { id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" }, + { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, + { id: "gpt-5.6-terra-review", name: "GPT 5.6 Terra Review", upstreamModelId: "gpt-5.6-terra", quotaFamily: "review" }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna" }, + { id: "gpt-5.6-luna-review", name: "GPT 5.6 Luna Review", upstreamModelId: "gpt-5.6-luna", quotaFamily: "review" }, { id: "gpt-5.5", name: "GPT 5.5" }, { id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" }, { id: "gpt-5.4", name: "GPT 5.4" }, { id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" }, { id: "gpt-5.4-mini", name: "GPT 5.4 Mini" }, { id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" }, - { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, - { id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" }, - { id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" }, - { id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" }, - { id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" }, - { id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" }, - { id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" }, - { id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" }, - { id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" }, - { id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, { id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" }, { id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, diff --git a/open-sse/providers/registry/cursor.js b/open-sse/providers/registry/cursor.js index ca0ecdb1..b8529373 100644 --- a/open-sse/providers/registry/cursor.js +++ b/open-sse/providers/registry/cursor.js @@ -23,7 +23,7 @@ export default { "Content-Type": "application/connect+proto", "User-Agent": "connect-es/1.6.1", }, - clientVersion: "3.1.0", + clientVersion: "3.12.17", }, models: [ { id: "default", name: "Auto (Server Picks)" }, @@ -44,11 +44,11 @@ export default { oauth: { apiEndpoint: "https://api2.cursor.sh", chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", + modelsEndpoint: "/agent.v1.AgentService/GetUsableModels", api3Endpoint: "https://api3.cursor.sh", agentEndpoint: "https://agent.api5.cursor.sh", agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", - clientVersion: "3.1.0", + clientVersion: "3.12.17", clientType: "ide", dbKeys: { accessToken: "cursorAuth/accessToken", diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 5c167f7a..86123b28 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -48,4 +48,8 @@ export default { { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js new file mode 100644 index 00000000..09391649 --- /dev/null +++ b/open-sse/providers/registry/devin-cli.js @@ -0,0 +1,63 @@ +export default { + id: "devin-cli", + alias: "dv", + aliases: ["devin"], + uiAlias: "dv", + hidden: true, + display: { + name: "Devin CLI", + icon: "smart_toy", + color: "#6366F1", + textIcon: "DV", + website: "https://devin.ai", + notice: { + signupUrl: "https://cli.devin.ai", + text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.", + }, + }, + category: "free", + authType: "none", + noAuth: true, + authModes: ["none"], + transport: { + baseUrl: "devin://acp/stdio", + format: "openai", + }, + models: [ + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 }, + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 }, + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 }, + { id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 }, + { id: "deepseek-v4", name: "DeepSeek V4", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM-5.1", contextLength: 204800 }, + ], +}; diff --git a/open-sse/providers/registry/featherless.js b/open-sse/providers/registry/featherless.js new file mode 100644 index 00000000..85240df7 --- /dev/null +++ b/open-sse/providers/registry/featherless.js @@ -0,0 +1,34 @@ +export default { + id: "featherless", + priority: 65, + alias: "featherless", + aliases: [ + "fl", + ], + uiAlias: "fl", + display: { + name: "Featherless", + icon: "flutter_dash", + color: "#111827", + textIcon: "FL", + website: "https://featherless.ai", + notice: { + apiKeyUrl: "https://featherless.ai/account/api-keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.featherless.ai/v1/chat/completions", + validateUrl: "https://api.featherless.ai/v1/models", + }, + models: [ + { id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-ai/DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" }, + { id: "zai-org/GLM-5.2", name: "GLM 5.2" }, + { id: "zai-org/GLM-5.1", name: "GLM 5.1" }, + { id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code" }, + { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" }, + { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, + ], +}; diff --git a/open-sse/providers/registry/fish-audio.js b/open-sse/providers/registry/fish-audio.js new file mode 100644 index 00000000..f016a247 --- /dev/null +++ b/open-sse/providers/registry/fish-audio.js @@ -0,0 +1,31 @@ +// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the +// JSON body, and the voice is a reference_id (a cloned or preset voice model). +export default { + id: "fish-audio", + alias: "fish", + display: { + name: "Fish Audio", + icon: "record_voice_over", + color: "#1E9BF0", + textIcon: "FA", + website: "https://fish.audio", + notice: { + apiKeyUrl: "https://fish.audio/app/api-keys/", + }, + }, + category: "apikey", + authType: "apikey", + serviceKinds: ["tts"], + ttsConfig: { + baseUrl: "https://api.fish.audio/v1/tts", + authType: "apikey", + authHeader: "bearer", + format: "fish-audio", + models: [ + { id: "s2.1-pro-free", name: "S2.1 Pro Free" }, + { id: "s2.1-pro", name: "S2.1 Pro" }, + { id: "s2-pro", name: "S2 Pro" }, + { id: "s1", name: "S1" }, + ], + }, +}; diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 5c811042..df643556 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -16,6 +16,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], mediaPriority: 1, transport: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", @@ -34,6 +36,9 @@ export default { }, }, models: [ + { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" }, + { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, diff --git a/open-sse/providers/registry/github.js b/open-sse/providers/registry/github.js index 95169eb3..1104eeb3 100644 --- a/open-sse/providers/registry/github.js +++ b/open-sse/providers/registry/github.js @@ -18,6 +18,7 @@ export default { transport: { baseUrl: "https://api.githubcopilot.com/chat/completions", responsesUrl: "https://api.githubcopilot.com/responses", + messagesUrl: "https://api.githubcopilot.com/v1/messages", headers: { "copilot-integration-id": "vscode-chat", "editor-version": "vscode/1.110.0", @@ -46,6 +47,14 @@ export default { { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { id: "gpt-5.4", name: "GPT-5.4" }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + // Note: routing to Copilot's Anthropic-native /v1/messages shim (see + // executors/github.js) is decided by model-NAME pattern at request time, not by + // a static targetFormat field here — Copilot's live model catalog (see + // services/copilotModels.js) regularly exposes claude-* models this static list + // hasn't caught up with yet (e.g. claude-opus-4.8), and a static per-entry + // targetFormat would silently miss those while also double-translating requests + // for models that ARE listed here (chatCore.js would pre-translate to Claude + // shape, then the executor would translate again). Keep these as plain entries. { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js index 90a71b4c..cff9eb92 100644 --- a/open-sse/providers/registry/glm-cn.js +++ b/open-sse/providers/registry/glm-cn.js @@ -21,6 +21,7 @@ export default { }, }, models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index 95e11533..9bc099b2 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -45,6 +45,7 @@ export default { }, ], models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/providers/registry/grok-cli.js b/open-sse/providers/registry/grok-cli.js new file mode 100644 index 00000000..403c1abe --- /dev/null +++ b/open-sse/providers/registry/grok-cli.js @@ -0,0 +1,96 @@ +/** + * Grok CLI / Grok Build (cli-chat-proxy.grok.com) + * + * Source of truth: wire capture of official @xai-official/grok 0.2.99 + * talking to https://cli-chat-proxy.grok.com (OpenAI Responses API). + * + * Distinct from: + * - `xai` → api.x.ai (API key / xAI API OAuth PKCE) + * - `grok-web` → grok.com web SSO cookie + */ +import { + GROK_CLI_BASE_URL, + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_MODEL, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../../config/grokCli.js"; + +export default { + id: "grok-cli", + priority: 275, + alias: "gcli", + aliases: ["grok-build", "gb"], + uiAlias: "gcli", + display: { + name: "Grok CLI (Grok Build)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "GC", + website: "https://x.ai", + notice: { + text: "Sign in with your xAI / Grok account via device code. Uses Grok Build subscription credits (cli-chat-proxy.grok.com).", + signupUrl: "https://grok.com/supergrok", + }, + }, + category: "oauth", + authModes: ["oauth"], + hasOAuth: true, + thinkingConfig: { + options: ["low", "medium", "high", "xhigh"], + defaultMode: "high", + }, + transport: { + baseUrl: `${GROK_CLI_BASE_URL}/responses`, + format: "openai-responses", + forceStream: true, + modelsUrl: `${GROK_CLI_BASE_URL}/models`, + userUrl: `${GROK_CLI_BASE_URL}/user`, + billingUrl: `${GROK_CLI_BASE_URL}/billing`, + clientVersion: GROK_CLI_VERSION, + clientIdentifier: GROK_CLI_CLIENT_IDENTIFIER, + tokenAuth: "xai-grok-cli", + headers: { + "User-Agent": GROK_CLI_USER_AGENT, + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-version": GROK_CLI_VERSION, + }, + // Quota tracker: official CLI polls billing?format=credits + user?include=subscription + usage: { + url: `${GROK_CLI_BASE_URL}/billing?format=credits`, + userUrl: `${GROK_CLI_BASE_URL}/user?include=subscription`, + }, + retry: { + 429: { attempts: 2, delayMs: 2000 }, + 502: { attempts: 2, delayMs: 1500 }, + 503: { attempts: 2, delayMs: 1500 }, + }, + }, + models: [ + { + id: GROK_CLI_MODEL, + name: "Grok Build", + contextLength: 500000, + maxOutputTokens: 64000, + }, + { id: "grok-4.5", name: "Grok 4.5" }, + { id: "grok-4.5-high", name: "Grok 4.5 (High)", upstreamModelId: "grok-4.5" }, + { id: "grok-4.5-medium", name: "Grok 4.5 (Medium)", upstreamModelId: "grok-4.5" }, + { id: "grok-4.5-low", name: "Grok 4.5 (Low)", upstreamModelId: "grok-4.5" }, + ], + features: { + usage: true, + }, + oauth: { + // Same public client_id as Grok CLI / existing xai OAuth + clientId: "b1a00492-073a-47ea-816f-4c329264a828", + deviceCodeUrl: "https://auth.x.ai/oauth2/device/code", + tokenUrl: "https://auth.x.ai/oauth2/token", + refreshUrl: "https://auth.x.ai/oauth2/token", + // HAR scope includes conversations read/write beyond the api-only xai scope + scope: + "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write", + referrer: "grok-build", + refreshLeadMs: 5 * 60 * 1000, + }, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 6f0f6826..6482293a 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -1,4 +1,4 @@ -// Auto-generated: static imports of all registry entries +// Auto-generated: static imports for all registry entries import p0 from "./alicode-intl.js"; import p1 from "./alicode.js"; import p2 from "./anthropic.js"; @@ -30,72 +30,97 @@ import p27 from "./edge-tts.js"; import p28 from "./elevenlabs.js"; import p29 from "./exa.js"; import p30 from "./fal-ai.js"; -import p31 from "./firecrawl.js"; -import p32 from "./fireworks.js"; -import p33 from "./gemini-cli.js"; -import p34 from "./gemini.js"; -import p35 from "./github.js"; -import p36 from "./gitlab.js"; -import p37 from "./glm-cn.js"; -import p38 from "./glm.js"; -import p39 from "./google-pse.js"; -import p40 from "./google-tts.js"; -import p41 from "./grok-web.js"; -import p42 from "./groq.js"; -import p43 from "./huggingface.js"; -import p44 from "./hyperbolic.js"; -import p45 from "./iflow.js"; -import p46 from "./inworld.js"; -import p47 from "./jina-ai.js"; -import p48 from "./jina-reader.js"; -import p49 from "./kilocode.js"; -import p50 from "./kimchi.js"; -import p51 from "./kimi-coding.js"; -import p52 from "./kimi.js"; -import p53 from "./kiro.js"; -import p54 from "./linkup.js"; -import p55 from "./local-device.js"; -import p56 from "./mimo-free.js"; -import p57 from "./minimax-cn.js"; -import p58 from "./minimax.js"; -import p59 from "./mistral.js"; -import p60 from "./mmf.js"; -import p61 from "./nanobanana.js"; -import p62 from "./nebius.js"; -import p63 from "./nvidia.js"; -import p64 from "./ollama-local.js"; -import p65 from "./ollama.js"; -import p66 from "./openai.js"; -import p67 from "./opencode-go.js"; -import p68 from "./opencode.js"; -import p69 from "./openrouter.js"; -import p70 from "./perplexity-web.js"; -import p71 from "./perplexity.js"; -import p72 from "./playht.js"; -import p73 from "./qoder.js"; -import p74 from "./qwen.js"; -import p75 from "./recraft.js"; -import p76 from "./runwayml.js"; -import p77 from "./sdwebui.js"; -import p78 from "./searchapi.js"; -import p79 from "./searxng.js"; -import p80 from "./serper.js"; -import p81 from "./siliconflow.js"; -import p82 from "./stability-ai.js"; -import p83 from "./tavily.js"; -import p84 from "./together.js"; -import p85 from "./topaz.js"; -import p86 from "./tortoise.js"; -import p87 from "./venice.js"; -import p88 from "./vercel-ai-gateway.js"; -import p89 from "./vertex-partner.js"; -import p90 from "./vertex.js"; -import p91 from "./volcengine-ark.js"; -import p92 from "./voyage-ai.js"; -import p93 from "./xai.js"; -import p94 from "./xiaomi-mimo.js"; -import p95 from "./xiaomi-tokenplan.js"; -import p96 from "./youcom.js"; +import p31 from "./featherless.js"; +import p32 from "./firecrawl.js"; +import p33 from "./fireworks.js"; +import p34 from "./gemini-cli.js"; +import p35 from "./gemini.js"; +import p36 from "./github.js"; +import p37 from "./gitlab.js"; +import p38 from "./glm-cn.js"; +import p39 from "./glm.js"; +import p40 from "./google-pse.js"; +import p41 from "./google-tts.js"; +import p42 from "./grok-cli.js"; +import p43 from "./grok-web.js"; +import p44 from "./groq.js"; +import p45 from "./huggingface.js"; +import p46 from "./hyperbolic.js"; +import p47 from "./iflow.js"; +import p48 from "./inworld.js"; +import p49 from "./jina-ai.js"; +import p50 from "./jina-reader.js"; +import p51 from "./kilocode.js"; +import p52 from "./kimchi.js"; +import p53 from "./kimi.js"; +import p54 from "./kiro.js"; +import p55 from "./linkup.js"; +import p56 from "./local-device.js"; +import p57 from "./mimo-free.js"; +import p58 from "./minimax-cn.js"; +import p59 from "./minimax.js"; +import p60 from "./mistral.js"; +import p61 from "./mmf.js"; +import p62 from "./nanobanana.js"; +import p63 from "./nebius.js"; +import p64 from "./nvidia.js"; +import p65 from "./ollama-local.js"; +import p66 from "./ollama.js"; +import p67 from "./openai.js"; +import p68 from "./opencode-go.js"; +import p69 from "./opencode.js"; +import p70 from "./openrouter.js"; +import p71 from "./perplexity-web.js"; +import p72 from "./perplexity.js"; +import p73 from "./perplexity-agent.js"; +import p74 from "./playht.js"; +import p75 from "./qoder.js"; +import p77 from "./recraft.js"; +import p78 from "./runwayml.js"; +import p79 from "./sdwebui.js"; +import p80 from "./searchapi.js"; +import p81 from "./searxng.js"; +import p82 from "./serper.js"; +import p83 from "./siliconflow.js"; +import p84 from "./stability-ai.js"; +import p85 from "./tavily.js"; +import p86 from "./together.js"; +import p87 from "./topaz.js"; +import p88 from "./tortoise.js"; +import p89 from "./venice.js"; +import p90 from "./vercel-ai-gateway.js"; +import p91 from "./vertex-partner.js"; +import p92 from "./vertex.js"; +import p93 from "./volcengine-ark.js"; +import p94 from "./voyage-ai.js"; +import p95 from "./xai.js"; +import p96 from "./xiaomi-mimo.js"; +import p97 from "./xiaomi-tokenplan.js"; +import p98 from "./youcom.js"; +import p99 from "./alims-intl.js"; +import p100 from "./codebuddy-intl.js"; +// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). +// Re-enable by uncommenting both the import and the array entry below. +// import p102 from "./trae.js"; +import p103 from "./zed.js"; +import p105 from "./api-airforce.js"; +import p106 from "./baidu.js"; +import p107 from "./bazaarlink.js"; +import p108 from "./bluesminds.js"; +import p109 from "./kilo-gateway.js"; +import p110 from "./llm7.js"; +import p111 from "./sambanova.js"; +import p112 from "./tencent.js"; +import p113 from "./morph.js"; +// import p114 from "./devin-cli.js"; +// import p104 from "./windsurf.js"; +import p115 from "./poolside.js"; +import p116 from "./tokenrouter.js"; +import p117 from "./selfhosted-stt.js"; +import p118 from "./selfhosted-tts.js"; +import p119 from "./selfhosted-embedding.js"; +import p120 from "./fish-audio.js"; +import p121 from "./alitp-intl.js"; export default [ p0, @@ -174,7 +199,6 @@ export default [ p73, p74, p75, - p76, p77, p78, p79, @@ -194,5 +218,29 @@ export default [ p93, p94, p95, - p96 + p96, + p97, + p98, + p99, + p100, + // p102, // trae — hidden, no tool calling + p103, + p105, + p106, + p107, + p108, + p109, + p110, + p111, + p112, + p113, + // p114, // devin-cli — hidden, spawns local agent with shell/fs access + // p104, // windsurf — hidden, no tool calling + p115, + p116, + p117, + p118, + p119, + p120, + p121, ]; diff --git a/open-sse/providers/registry/kilo-gateway.js b/open-sse/providers/registry/kilo-gateway.js new file mode 100644 index 00000000..7e11cddc --- /dev/null +++ b/open-sse/providers/registry/kilo-gateway.js @@ -0,0 +1,34 @@ +export default { + id: "kilo-gateway", + alias: "kgw", + aliases: [ + "kilo-gateway", + "kilogateway", + ], + uiAlias: "kgw", + category: "freeTier", + display: { + name: "Kilo Gateway", + icon: "login", + color: "#8B5CF6", + textIcon: "KG", + website: "https://kilo.ai", + notice: { + apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys", + }, + }, + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.kilo.ai/api/gateway/chat/completions", + validateUrl: "https://api.kilo.ai/api/gateway/models", + }, + models: [ + { id: "kilo-auto/free", name: "Kilo Auto Free", contextLength: 256000 }, + { id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)", contextLength: 262144 }, + { id: "nvidia/nemotron-3-ultra-550b-a55b:free", name: "Nemotron 3 Ultra 550B (Free)", contextLength: 1000000 }, + { id: "kwaipilot/kat-coder-pro-v2.5:free", name: "Kat Coder Pro v2.5 (Free)", contextLength: 256000 }, + { id: "kilo-auto/frontier", name: "Kilo Auto Frontier", contextLength: 1000000 }, + { id: "kilo-auto/balanced", name: "Kilo Auto Balanced", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js index 99facd53..1fe0b706 100644 --- a/open-sse/providers/registry/kimchi.js +++ b/open-sse/providers/registry/kimchi.js @@ -13,8 +13,8 @@ export default { signupUrl: "https://app.kimchi.dev", }, }, - category: "oauth", - authModes: ["oauth"], + category: "freeTier", + authModes: ["oauth", "apikey"], hasOAuth: true, transport: { baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions", diff --git a/open-sse/providers/registry/kimi-coding.js b/open-sse/providers/registry/kimi-coding.js deleted file mode 100644 index 15705a86..00000000 --- a/open-sse/providers/registry/kimi-coding.js +++ /dev/null @@ -1,65 +0,0 @@ -import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; - -export default { - id: "kimi-coding", - hidden: true, - priority: 120, - alias: "kmc", - display: { - name: "Kimi Coding", - icon: "psychology", - color: "#1E40AF", - textIcon: "KC", - website: "https://kimi.moonshot.cn", - notice: { - signupUrl: "https://kimi.moonshot.cn", - }, - }, - category: "oauth", - transport: { - baseUrl: "https://api.kimi.com/coding/v1/messages", - format: "claude", - urlSuffix: "?beta=true", - headers: { ...CLAUDE_API_HEADERS }, - clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", - tokenUrl: "https://auth.kimi.com/api/oauth/token", - refreshUrl: "https://auth.kimi.com/api/oauth/token", - auth: { - combined: true, - header: "x-api-key", - scheme: "raw", - hooks: [ - "kimiHeaders", - ], - }, - }, - // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. - transports: [ - { - format: "openai", - baseUrl: "https://api.kimi.com/coding/v1/chat/completions", - auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] }, - }, - { - format: "claude", - baseUrl: "https://api.kimi.com/coding/v1/messages", - urlSuffix: "?beta=true", - headers: { ...CLAUDE_API_HEADERS }, - auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] }, - }, - ], - models: [ - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, - { id: "kimi-latest", name: "Kimi Latest" }, - ], - oauth: { - deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", - tokenUrl: "https://auth.kimi.com/api/oauth/token", - refreshLeadMs: 300000, - }, - features: { - usage: true, - }, -}; diff --git a/open-sse/providers/registry/kimi.js b/open-sse/providers/registry/kimi.js index e22286b4..299b824f 100644 --- a/open-sse/providers/registry/kimi.js +++ b/open-sse/providers/registry/kimi.js @@ -1,9 +1,14 @@ -import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; +import { CLAUDE_API_HEADERS } from "../shared.js"; +// Dual auth (same pattern as xai): OAuth = Kimi Code subscription (device code), +// API key = platform.moonshot / api.kimi.com. Transport is shared. +// CLIProxyAPI parity: client_id, auth.kimi.com device+token, X-Msh-* headers, device_id. export default { id: "kimi", priority: 170, alias: "kimi", + // Legacy id + short alias from former kimi-coding registry entry + aliases: ["kimi-coding", "kmc"], display: { name: "Kimi", icon: "psychology", @@ -12,18 +17,25 @@ export default { website: "https://kimi.moonshot.cn", notice: { apiKeyUrl: "https://platform.moonshot.ai/console/api-keys", + signupUrl: "https://www.kimi.com/code", }, }, - category: "apikey", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, transport: { baseUrl: "https://api.kimi.com/coding/v1/messages", format: "claude", urlSuffix: "?beta=true", headers: { ...CLAUDE_API_HEADERS }, + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token", auth: { combined: true, header: "x-api-key", scheme: "raw", + hooks: ["kimiHeaders"], }, }, // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. @@ -31,26 +43,50 @@ export default { { format: "openai", baseUrl: "https://api.kimi.com/coding/v1/chat/completions", - auth: { combined: true, header: "Authorization", scheme: "bearer" }, + auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] }, }, { format: "claude", baseUrl: "https://api.kimi.com/coding/v1/messages", urlSuffix: "?beta=true", headers: { ...CLAUDE_API_HEADERS }, - auth: { combined: true, header: "x-api-key", scheme: "raw" }, + auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] }, }, ], models: [ + // Flagship K3 — platform.kimi.ai id `kimi-k3`, Kimi Code OAuth id `k3` (up to 1M) + { id: "kimi-k3", name: "Kimi K3" }, + { id: "k3", name: "Kimi K3 (Code)" }, + // Kimi Code subscription stable ids (map to K2.7 Code backend) + { id: "kimi-for-coding", name: "Kimi for Coding" }, + { id: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" }, + // Pay-as-you-go platform ids + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, + { id: "kimi-k2.7-code-highspeed", name: "Kimi K2.7 Code Highspeed" }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, { id: "kimi-latest", name: "Kimi Latest" }, ], - serviceKinds: ["llm","webSearch"], + serviceKinds: ["llm", "webSearch"], searchViaChat: { - defaultModel: "kimi-k2.5", + defaultModel: "kimi-k3", endpoint: "https://api.moonshot.cn/v1/chat/completions", - pricingUrl: "https://platform.moonshot.ai/docs/pricing/chat", + pricingUrl: "https://platform.kimi.ai/docs/pricing/chat", + }, + oauth: { + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token", + // CLIProxyAPI refreshThresholdSeconds = 300 + refreshLeadMs: 300000, + authorizeDeviceUrl: "https://www.kimi.com/code/authorize_device", + }, + features: { + usage: true, + // API-key connections also hit /v1/usages (x-api-key) — need usageApikey + // so isUsageEligible + /api/usage allow non-oauth authType. + usageApikey: true, }, }; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 12015643..f506b8d4 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -29,7 +29,6 @@ export default { headers: { "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", }, @@ -42,22 +41,57 @@ export default { }, }, models: [ + // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" }, + { id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" }, + { id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" }, + { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, + { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, + { id: "claude-opus-4.8-thinking-agentic", name: "Claude Opus 4.8 (Thinking + Agentic)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "claude-opus-4.7-thinking", name: "Claude Opus 4.7 (Thinking)" }, + { id: "claude-opus-4.7-agentic", name: "Claude Opus 4.7 (Agentic)" }, + { id: "claude-opus-4.7-thinking-agentic", name: "Claude Opus 4.7 (Thinking + Agentic)" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 (Thinking)" }, + { id: "claude-opus-4.5-agentic", name: "Claude Opus 4.5 (Agentic)" }, + { id: "claude-opus-4.5-thinking-agentic", name: "Claude Opus 4.5 (Thinking + Agentic)" }, + // Sonnet { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + // Haiku { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // Non-Anthropic { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" }, + { id: "gpt-5.6-terra", name: "GPT 5.6 Terra", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" }, + // Thinking variants { id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" }, { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + { id: "gpt-5.6-sol-thinking", name: "GPT 5.6 Sol (Thinking)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" }, + { id: "gpt-5.6-terra-thinking", name: "GPT 5.6 Terra (Thinking)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" }, + { id: "gpt-5.6-luna-thinking", name: "GPT 5.6 Luna (Thinking)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" }, + // Agentic variants { id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" }, { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + { id: "gpt-5.6-sol-agentic", name: "GPT 5.6 Sol (Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" }, + { id: "gpt-5.6-terra-agentic", name: "GPT 5.6 Terra (Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" }, + { id: "gpt-5.6-luna-agentic", name: "GPT 5.6 Luna (Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" }, + // Thinking + Agentic variants { id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" }, { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, + { id: "gpt-5.6-sol-thinking-agentic", name: "GPT 5.6 Sol (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" }, + { id: "gpt-5.6-terra-thinking-agentic", name: "GPT 5.6 Terra (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" }, + { id: "gpt-5.6-luna-thinking-agentic", name: "GPT 5.6 Luna (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" }, ], oauth: { ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com", diff --git a/open-sse/providers/registry/llm7.js b/open-sse/providers/registry/llm7.js new file mode 100644 index 00000000..6f4210a7 --- /dev/null +++ b/open-sse/providers/registry/llm7.js @@ -0,0 +1,35 @@ +export default { + id: "llm7", + alias: "llm7", + aliases: [ + "llm-7", + ], + uiAlias: "llm7", + display: { + name: "LLM7", + icon: "pool", + color: "#7C3AED", + textIcon: "L7", + website: "https://llm7.io", + notice: { + apiKeyUrl: "https://llm7.io", + }, + }, + category: "apikey", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.llm7.io/v1/chat/completions", + validateUrl: "https://api.llm7.io/v1/models", + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5 (LLM7)", contextLength: 1050000 }, + { id: "claude-opus-5", name: "Claude Opus 5 (LLM7)", contextLength: 1000000 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash (LLM7)", contextLength: 1000000 }, + { id: "grok-4.5", name: "Grok 4.5 (LLM7)", contextLength: 500000 }, + { id: "kimi-k3", name: "Kimi K3 (LLM7)", contextLength: 1000000 }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/mimo-free.js b/open-sse/providers/registry/mimo-free.js index 4b9074d1..84911120 100644 --- a/open-sse/providers/registry/mimo-free.js +++ b/open-sse/providers/registry/mimo-free.js @@ -1,5 +1,8 @@ +// Xiaomi ended the free MiMo channel ("MiMo free API service has ended"). +// Hidden until/unless a replacement (OAuth MiMo Platform) is wired. export default { id: "mimo-free", + hidden: true, priority: 50, hasFree: true, alias: "mmf", diff --git a/open-sse/providers/registry/morph.js b/open-sse/providers/registry/morph.js new file mode 100644 index 00000000..e88a3639 --- /dev/null +++ b/open-sse/providers/registry/morph.js @@ -0,0 +1,29 @@ +export default { + id: "morph", + alias: "morph", + aliases: ["morphllm"], + uiAlias: "morph", + display: { + name: "Morph", + icon: "change_history", + color: "#14B8A6", + textIcon: "MP", + website: "https://morphllm.com", + notice: { apiKeyUrl: "https://morphllm.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.morphllm.com/v1/chat/completions", + validateUrl: "https://api.morphllm.com/v1/models", + }, + models: [ + { id: "morph-v3-large", name: "Morph v3 Large" }, + { id: "morph-v3-fast", name: "Morph v3 Fast" }, + { id: "morph-qwen35-397b", name: "Qwen 3.5 397B (Morph)", contextLength: 262144 }, + { id: "morph-minimax27-230b", name: "MiniMax M2.7 (Morph)", contextLength: 200704 }, + { id: "morph-qwen36-27b", name: "Qwen 3.6 27B (Morph)", contextLength: 262144 }, + { id: "morph-dsv4flash", name: "DeepSeek V4 Flash (Morph)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js index 9522611a..4d375a17 100644 --- a/open-sse/providers/registry/nvidia.js +++ b/open-sse/providers/registry/nvidia.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", validateUrl: "https://integrate.api.nvidia.com/v1/models", diff --git a/open-sse/providers/registry/ollama.js b/open-sse/providers/registry/ollama.js index 69923aa1..89fec43c 100644 --- a/open-sse/providers/registry/ollama.js +++ b/open-sse/providers/registry/ollama.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://ollama.com/api/chat", validateUrl: "https://ollama.com/api/tags", @@ -32,5 +34,6 @@ export default { serviceKinds: ["llm"], features: { usage: true, + usageApikey: true, }, }; diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c980cc85..4b189ba8 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -22,20 +22,28 @@ export default { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", headers: {}, }, + // Multi-endpoint: pick the transport matching the client sourceFormat to skip + // translation. Guarded per-model by `supportedFormats` (see chatCore) because + // opencode-go models differ in endpoint support. + transports: [ + { format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + { format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, + { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + ], models: [ - { id: "glm-5.2", name: "GLM 5.2" }, - { id: "glm-5.1", name: "GLM 5.1" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "mimo-v2.5", name: "MiMo V2.5" }, - { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, - { id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" }, - { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, - { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, - { id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" }, - { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, - { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, + { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, + { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, + { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, + { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, + { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, ], }; diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 4ac03641..a0df2a52 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", thinkingFormat: "openai", diff --git a/open-sse/providers/registry/perplexity-agent.js b/open-sse/providers/registry/perplexity-agent.js new file mode 100644 index 00000000..5c8785eb --- /dev/null +++ b/open-sse/providers/registry/perplexity-agent.js @@ -0,0 +1,49 @@ +export default { + id: "perplexity-agent", + priority: 181, + alias: "perplexity-agent", + aliases: [ + "pplx-agent", + "pplx-responses", + ], + uiAlias: "pa", + display: { + name: "Perplexity Agent", + icon: "travel_explore", + color: "#20808D", + textIcon: "PA", + website: "https://www.perplexity.ai", + notice: { + text: "Perplexity Agent API exposes GPT, Claude, Gemini, Grok, GLM, Kimi, and Sonar models through one OpenAI-compatible Responses API.", + apiKeyUrl: "https://www.perplexity.ai/settings/api", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.perplexity.ai/v1/responses", + validateUrl: "https://api.perplexity.ai/v1/models", + format: "openai-responses", + }, + models: [ + { id: "perplexity/sonar", name: "Perplexity Sonar" }, + { id: "openai/gpt-5.5", name: "GPT-5.5" }, + { id: "openai/gpt-5.4", name: "GPT-5.4" }, + { id: "openai/gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "anthropic/claude-opus-4-8", name: "Claude Opus 4.8" }, + { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, + { id: "xai/grok-4.20-reasoning", name: "Grok 4.20 Reasoning" }, + { id: "perplexity/glm-5.2", name: "GLM 5.2" }, + { id: "perplexity/kimi-k2.7-code", name: "Kimi K2.7 Code" }, + { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B" }, + ], + serviceKinds: ["llm", "webSearch"], + searchViaChat: { + defaultModel: "perplexity/sonar", + endpoint: "https://api.perplexity.ai/v1/responses", + pricingUrl: "https://docs.perplexity.ai/docs/agent-api/models", + }, + modelsFetcher: { url: "https://api.perplexity.ai/v1/models", type: "openai" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/poolside.js b/open-sse/providers/registry/poolside.js new file mode 100644 index 00000000..02882ad9 --- /dev/null +++ b/open-sse/providers/registry/poolside.js @@ -0,0 +1,30 @@ +export default { + id: "poolside", + priority: 60, + alias: "poolside", + aliases: [ + "ps", + ], + uiAlias: "ps", + display: { + name: "Poolside", + icon: "water_drop", + color: "#0EA5E9", + textIcon: "PS", + website: "https://poolside.ai", + notice: { + apiKeyUrl: "https://platform.poolside.ai/api-keys", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://inference.poolside.ai/v1/chat/completions", + validateUrl: "https://inference.poolside.ai/v1/models", + }, + models: [ + { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, + ], +}; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index 4ee2b52f..fe76fd72 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -11,10 +11,11 @@ export default { notice: { signupUrl: "https://qoder.com", }, - deprecated: true, - deprecationNotice: "RISK_NOTICE", }, - category: "free", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations", transport: { baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", headers: {}, @@ -25,18 +26,19 @@ export default { }, }, models: [ - // { id: "auto", name: "Qoder Auto" }, - // { id: "ultimate", name: "Qoder Ultimate" }, - // { id: "performance", name: "Qoder Performance" }, - // { id: "efficient", name: "Qoder Efficient" }, - // { id: "lite", name: "Qoder Lite" }, - // { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, - { id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, - // { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, - // { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, - // { id: "gm51model", name: "GLM 5.1 (Qoder)" }, - // { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, - // { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + { id: "ultimate", name: "Ultimate" }, + { id: "auto", name: "Auto" }, + { id: "performance", name: "Performance" }, + { id: "efficient", name: "Efficient" }, + { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" }, + { id: "qmodel_latest", name: "Qwen3.7-Max" }, + { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "kmodel_latest", name: "Kimi-K3" }, + { id: "kmodel", name: "Kimi-K2.7-Code" }, + { id: "gm51model", name: "GLM-5.2" }, + { id: "dmodel", name: "DeepSeek-V4-Pro" }, + { id: "dfmodel", name: "DeepSeek-V4-Flash" }, + { id: "mmodel", name: "MiniMax-M3" }, ], oauth: { openApiBaseUrl: "https://openapi.qoder.sh", @@ -50,5 +52,7 @@ export default { }, features: { usage: true, + // PAT (apikey) connections also carry quota usage (via job-token exchange). + usageApikey: true, }, }; diff --git a/open-sse/providers/registry/qwen.js b/open-sse/providers/registry/qwen.js deleted file mode 100644 index 0df381ab..00000000 --- a/open-sse/providers/registry/qwen.js +++ /dev/null @@ -1,33 +0,0 @@ -export default { - id: "qwen", - hidden: true, - priority: 130, - alias: "qw", - display: { - name: "Qwen Code", - icon: "psychology", - color: "#10B981", - website: "https://chat.qwen.ai", - notice: { - signupUrl: "https://chat.qwen.ai", - }, - }, - category: "oauth", - transport: { - baseUrl: "https://portal.qwen.ai/v1/chat/completions", - }, - models: [ - { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, - { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, - { id: "vision-model", name: "Qwen3 Vision Model" }, - { id: "coder-model", name: "Qwen3.6 Coder Model" }, - ], - oauth: { - clientId: "f0304373b74a44d2b584a3fb70ca9e56", - deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", - tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", - scope: "openid profile email model.completion", - codeChallengeMethod: "S256", - refreshLeadMs: 1200000, - }, -}; diff --git a/open-sse/providers/registry/sambanova.js b/open-sse/providers/registry/sambanova.js new file mode 100644 index 00000000..9c7e7174 --- /dev/null +++ b/open-sse/providers/registry/sambanova.js @@ -0,0 +1,27 @@ +export default { + id: "sambanova", + alias: "samba", + aliases: ["sambanova-ai"], + uiAlias: "samba", + hidden: true, + display: { + name: "SambaNova", + icon: "memory", + color: "#F97316", + textIcon: "SN", + website: "https://sambanova.ai", + notice: { + apiKeyUrl: "https://cloud.sambanova.ai/apis", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.sambanova.ai/v1/chat/completions", + validateUrl: "https://api.sambanova.ai/v1/models", + }, + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7", contextLength: 196608 }, + ], +}; diff --git a/open-sse/providers/registry/searxng.js b/open-sse/providers/registry/searxng.js index 308eabbc..bfbe4fdb 100644 --- a/open-sse/providers/registry/searxng.js +++ b/open-sse/providers/registry/searxng.js @@ -1,3 +1,5 @@ +import { SEARXNG_URL } from "../../config/runtimeConfig.js"; + export default { id: "searxng", alias: "searxng", @@ -15,7 +17,7 @@ export default { ], noAuth: true, searchConfig: { - baseUrl: "http://localhost:8888/search", + baseUrl: SEARXNG_URL, method: "GET", authType: "none", authHeader: "none", diff --git a/open-sse/providers/registry/selfhosted-embedding.js b/open-sse/providers/registry/selfhosted-embedding.js new file mode 100644 index 00000000..90fa79c7 --- /dev/null +++ b/open-sse/providers/registry/selfhosted-embedding.js @@ -0,0 +1,73 @@ +// Self-hosted, OpenAI-compatible embeddings (llama.cpp / llama-server, vLLM, +// Infinity, text-embeddings-inference, ...) — the embeddings counterpart of +// selfhosted-stt and selfhosted-tts. +// +// Routing a self-hosted embeddings server already WORKS today, via a custom +// provider node: getEmbeddingAdapter() matches `openai-compatible-*` and +// `custom-embedding-*` and returns openaiCompatNode, whose buildUrl reads +// creds.providerSpecificData.baseUrl. What is missing is a first-class provider, +// and the gap is visible rather than functional: +// +// /v1/embeddings on such a node -> 200, correct vectors +// the Embedding page in the dashboard -> the node is not listed at all +// +// The page renders getProvidersByKind("embedding") plus provider nodes filtered +// to `type === "custom-embedding"`. A node created as `openai-compatible` — the +// natural choice when ONE endpoint serves chat and embeddings behind the same +// front door — satisfies neither, so a working self-hosted embeddings endpoint is +// invisible on the page whose job is to show embeddings providers. Diagnosed on a +// deployment serving Qwen3-Embedding-8B at 4096 dimensions through exactly that +// shape (2026-08-04). +// +// Declaring it as a provider with serviceKinds: ["embedding"] puts it on the page +// beside Voyage, Jina and the rest, and keeps the per-connection baseUrl that +// makes self-hosting possible at all. +// +// authType is "apikey" rather than "none" for the same reason as the STT and TTS +// entries: it is what gives the connection a credentials record, and +// providerSpecificData.baseUrl lives there. Local servers ignore the key itself; +// any non-empty value works. +export default { + id: "selfhosted-embedding", + priority: 50, + hasFree: true, + alias: "selfhosted-embedding", + display: { + name: "Self-hosted Embedding", + icon: "cloud", + color: "#ffffffff", + textIcon: "SE", + website: "https://github.com/ggml-org/llama.cpp", + }, + category: "apikey", + auth: { + apiKey: { + // Note the /v1: the adapter appends "/embeddings" to whatever it is given, + // so a bare http://host:8080 resolves to http://host:8080/embeddings and + // misses the OpenAI route entirely. Give it the OpenAI base, the same value + // an OpenAI client would use. A trailing /embeddings is tolerated. + text: "Set providerSpecificData.baseUrl to the OpenAI base URL, e.g. http://host:8080/v1 — /embeddings is appended. The API key is not checked by local servers; any value works.", + }, + }, + // A self-hosted server serves whatever model it was started with, so the id + // here is a placeholder for the UI: the request passes `model` straight + // through, and llama-server ignores an unknown value rather than rejecting it. + // Dimensions are deliberately NOT declared — they are a property of the loaded + // weights, and asserting a number here would be a guess that silently + // contradicts the server. + models: [ + { id: "embedding", name: "Self-hosted embedding model", kind: "embedding" }, + ], + serviceKinds: ["embedding"], + embeddingConfig: { + // Declared for shape-consistency with the other embedding providers, and + // read by the UI — but NOT by the request path. openaiCompatNode resolves the + // URL purely from creds.providerSpecificData.baseUrl (falling back to + // api.openai.com), so unlike a fixed cloud provider this baseUrl never + // reaches the wire. Stated plainly because a reader would otherwise + // reasonably assume it is the default endpoint. + baseUrl: "http://localhost:8080/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + }, +}; diff --git a/open-sse/providers/registry/selfhosted-stt.js b/open-sse/providers/registry/selfhosted-stt.js new file mode 100644 index 00000000..d2d9bc80 --- /dev/null +++ b/open-sse/providers/registry/selfhosted-stt.js @@ -0,0 +1,48 @@ +// Self-hosted, OpenAI-compatible speech-to-text (whisper.cpp, faster-whisper, +// Speaches, vLLM-served Whisper, ...). +// +// Every other STT provider here is a named cloud service with a fixed endpoint. +// This one exists so a locally-served /v1/audio/transcriptions can be used at +// all: set the connection's providerSpecificData.baseUrl to the full URL of the +// endpoint, exactly as the custom embedding providers already work. +// +// sttCore dispatches on `format`; anything that is not one of the five named +// cloud shapes falls through to transcribeOpenAICompatible, which POSTs the +// standard multipart body (file, model, and optional language / prompt / +// response_format / temperature). That is precisely what whisper.cpp's OpenAI +// endpoint accepts. +// +// authType is "apikey" rather than "none" so the connection carries a +// credentials record — which is where providerSpecificData.baseUrl lives. Local +// servers ignore the key itself; any non-empty value works. +export default { + id: "selfhosted-stt", + priority: 50, + hasFree: true, + alias: "selfhosted-stt", + display: { + name: "Self-hosted STT", + icon: "cloud", + color: "#ffffffff", + textIcon: "ST", + website: "https://github.com/ggml-org/whisper.cpp", + }, + category: "apikey", + auth: { + apiKey: { + text: "Set providerSpecificData.baseUrl to the full transcriptions URL, e.g. http://host:8080/v1/audio/transcriptions. The API key is not checked by local servers; any value works.", + }, + }, + models: [ + { id: "whisper-1", name: "Whisper (self-hosted)", params: ["language", "response_format", "temperature", "prompt"], kind: "stt" }, + ], + serviceKinds: ["stt"], + sttConfig: { + // Overridden per connection by providerSpecificData.baseUrl; this default + // only makes the provider usable out of the box on a same-host deployment. + baseUrl: "http://localhost:8080/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + format: "openai", + }, +}; diff --git a/open-sse/providers/registry/selfhosted-tts.js b/open-sse/providers/registry/selfhosted-tts.js new file mode 100644 index 00000000..ff0a67b1 --- /dev/null +++ b/open-sse/providers/registry/selfhosted-tts.js @@ -0,0 +1,44 @@ +// Self-hosted, OpenAI-compatible text-to-speech (Kokoro-FastAPI, openedai-speech, +// vLLM-served TTS, ...) — the TTS counterpart of selfhosted-stt. +// +// Every other self-hostable TTS provider here (coqui, tortoise) carries a FIXED +// localhost baseUrl in its registry entry and `authType: "none"`, and the generic +// dispatcher reads `ttsConfig.baseUrl` from that entry rather than from the +// connection. So there was no way to point TTS at a server on another host. +// +// `authType: "apikey"` is what makes the override possible at all: it gives the +// connection a credentials record, which is where providerSpecificData.baseUrl +// lives. Local servers ignore the key; any non-empty value works. +export default { + id: "selfhosted-tts", + priority: 50, + hasFree: true, + alias: "selfhosted-tts", + display: { + name: "Self-hosted TTS", + icon: "cloud", + color: "#ffffffff", + textIcon: "TT", + website: "https://github.com/remsky/Kokoro-FastAPI", + }, + category: "apikey", + auth: { + apiKey: { + text: "Set providerSpecificData.baseUrl to the server root, e.g. http://host:8080 — /v1/audio/speech is appended. The API key is not checked by local servers; any value works.", + }, + }, + // Voice is selected as "/", the same convention the OpenAI TTS + // adapter uses, so existing clients need no special casing. + models: [ + { id: "kokoro", name: "Kokoro (self-hosted)", params: ["voice", "response_format", "speed"], kind: "tts" }, + ], + serviceKinds: ["tts"], + ttsConfig: { + // Overridden per connection by providerSpecificData.baseUrl; this default + // only makes the provider usable on a same-host deployment. + baseUrl: "http://localhost:8880", + defaultModel: "kokoro", + authType: "apikey", + format: "openai-speech", + }, +}; diff --git a/open-sse/providers/registry/tencent.js b/open-sse/providers/registry/tencent.js new file mode 100644 index 00000000..45876b67 --- /dev/null +++ b/open-sse/providers/registry/tencent.js @@ -0,0 +1,27 @@ +export default { + id: "tencent", + alias: "hunyuan", + aliases: ["hunyuan", "tencent-hunyuan"], + uiAlias: "hunyuan", + display: { + name: "Tencent Hunyuan", + icon: "cloud", + color: "#0052D9", + textIcon: "HY", + website: "https://cloud.tencent.com/product/hunyuan", + notice: { + apiKeyUrl: "https://console.cloud.tencent.com/hunyuan/api-key", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + validateUrl: "https://api.hunyuan.cloud.tencent.com/v1/models", + }, + models: [ + { id: "hunyuan-turbos-latest", name: "Hunyuan TurboS Latest", contextLength: 200000 }, + { id: "hunyuan-t1-latest", name: "Hunyuan T1 Latest", contextLength: 256000 }, + ], +}; diff --git a/open-sse/providers/registry/tokenrouter.js b/open-sse/providers/registry/tokenrouter.js new file mode 100644 index 00000000..2e3cd248 --- /dev/null +++ b/open-sse/providers/registry/tokenrouter.js @@ -0,0 +1,162 @@ +export default { + id: "tokenrouter", + alias: "tokenrouter", + aliases: ["tr"], + uiAlias: "tokenrouter", + display: { + name: "TokenRouter", + icon: "hub", + color: "#0EA5E9", + textIcon: "TR", + website: "https://www.tokenrouter.com", + notice: { + text: "OpenAI-compatible gateway. 300+ models (OpenAI, Claude, Gemini, Qwen, DeepSeek, Kimi, GLM, dsb).", + apiKeyUrl: "https://www.tokenrouter.com", + }, + }, + category: "apikey", + thinkingConfig: { + options: ["low", "medium", "high", "xhigh", "max"], + defaultMode: "high", + }, + transport: { + baseUrl: "https://api.tokenrouter.com/v1/chat/completions", + validateUrl: "https://api.tokenrouter.com/v1/models", + thinkingFormat: "tokenrouter", + }, + // Seed snapshot from live /v1/models (120 entries). Latest catalogue is + // fetched via modelsFetcher; other ids still accepted via passthroughModels. + models: [ + { id: "MiniMax-Hailuo-2.3", name: "Minimax Hailuo 2.3", kind: "video" }, + { id: "MiniMax-M3", name: "Minimax M3" }, + { id: "anthropic/claude-fable-5", name: "Claude Fable 5" }, + { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { id: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, + { id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "anthropic/claude-opus-4.7-fast", name: "Claude Opus 4.7 Fast" }, + { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "anthropic/claude-opus-4.8-fast", name: "Claude Opus 4.8 Fast" }, + { id: "anthropic/claude-opus-5", name: "Claude Opus 5" }, + { id: "anthropic/claude-opus-5-fast", name: "Claude Opus 5 Fast" }, + { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4" }, + { id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" }, + { id: "bytedance-seed/seedream-4.5", name: "Seedream 4.5", kind: "image" }, + { id: "bytedance-seed/seedream-5.0-lite", name: "Seedream 5.0 Lite", kind: "image" }, + { id: "bytedance-seed/seedream-5.0-pro", name: "Seedream 5.0 Pro", kind: "image" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4 5" }, + { id: "claude-opus-4-8-m-aws", name: "Claude Opus 4 8 M Aws" }, + { id: "deepseek/deepseek-v3.2", name: "Deepseek V3.2" }, + { id: "deepseek/deepseek-v4-flash", name: "Deepseek V4 Flash" }, + { id: "deepseek/deepseek-v4-flash-0731", name: "Deepseek V4 Flash 0731" }, + { id: "deepseek/deepseek-v4-pro", name: "Deepseek V4 Pro" }, + { id: "ex/gpt-5.4", name: "Gpt 5.4" }, + { id: "google/gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image" }, + { id: "google/gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "google/gemini-3-pro-image-preview", name: "Gemini 3 Pro Image Preview" }, + { id: "google/gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" }, + { id: "google/gemini-3.1-flash-lite-image", name: "Gemini 3.1 Flash Lite Image" }, + { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "google/gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, + { id: "google/gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { id: "google/gemini-embedding-2", name: "Gemini Embedding 2" }, + { id: "google/gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B It" }, + { id: "happyhorse-1.0-t2v", name: "Happyhorse 1.0 T2V", kind: "video" }, + { id: "kling-3.0-turbo", name: "Kling 3.0 Turbo", kind: "video" }, + { id: "kling-v2-6", name: "Kling V2 6", kind: "video" }, + { id: "kling-v3", name: "Kling V3", kind: "video" }, + { id: "kling-v3-omni", name: "Kling V3 Omni", kind: "video" }, + { id: "microsoft/mai-image-2.5", name: "Mai Image 2.5" }, + { id: "minimax/minimax-m2-her", name: "Minimax M2 Her" }, + { id: "minimax/minimax-m2.1", name: "Minimax M2.1" }, + { id: "minimax/minimax-m2.1-highspeed", name: "Minimax M2.1 Highspeed" }, + { id: "minimax/minimax-m2.5", name: "Minimax M2.5" }, + { id: "minimax/minimax-m2.7", name: "Minimax M2.7" }, + { id: "minimax/minimax-m2.7-highspeed", name: "Minimax M2.7 Highspeed" }, + { id: "miromind/mirothinker-1-7-deepresearch", name: "Mirothinker 1 7 Deepresearch" }, + { id: "miromind/mirothinker-1-7-deepresearch-mini", name: "Mirothinker 1 7 Deepresearch Mini" }, + { id: "mistralai/devstral-2512", name: "Devstral 2512" }, + { id: "mistralai/mistral-medium-3-5", name: "Mistral Medium 3 5" }, + { id: "mistralai/mistral-small-2603", name: "Mistral Small 2603" }, + { id: "mistralai/voxtral-small-24b-2507", name: "Voxtral Small 24B 2507" }, + { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, + { id: "moonshotai/kimi-k2.7-code", name: "Kimi K2.7 Code" }, + { id: "moonshotai/kimi-k3", name: "Kimi K3" }, + { id: "moonshotai/kimi-k3-free", name: "Kimi K3 Free" }, + { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", name: "Nemotron 3 Nano Omni 30B A3B Reasoning:Free" }, + { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, + { id: "openai/gpt-4o-mini", name: "Gpt 4O Mini" }, + { id: "openai/gpt-5", name: "Gpt 5" }, + { id: "openai/gpt-5-image", name: "Gpt 5 Image" }, + { id: "openai/gpt-5-image-mini", name: "Gpt 5 Image Mini" }, + { id: "openai/gpt-5-mini", name: "Gpt 5 Mini" }, + { id: "openai/gpt-5.2", name: "Gpt 5.2" }, + { id: "openai/gpt-5.4", name: "Gpt 5.4" }, + { id: "openai/gpt-5.4-image-2", name: "Gpt 5.4 Image 2", kind: "image" }, + { id: "openai/gpt-5.4-mini", name: "Gpt 5.4 Mini" }, + { id: "openai/gpt-5.4-nano", name: "Gpt 5.4 Nano" }, + { id: "openai/gpt-5.4-pro", name: "Gpt 5.4 Pro" }, + { id: "openai/gpt-5.5", name: "Gpt 5.5" }, + { id: "openai/gpt-5.5-pro", name: "Gpt 5.5 Pro" }, + { id: "openai/gpt-5.6-luna", name: "Gpt 5.6 Luna" }, + { id: "openai/gpt-5.6-sol", name: "Gpt 5.6 Sol" }, + { id: "openai/gpt-5.6-terra", name: "Gpt 5.6 Terra" }, + { id: "openai/gpt-audio", name: "Gpt Audio", kind: "audio" }, + { id: "openai/gpt-audio-mini", name: "Gpt Audio Mini", kind: "audio" }, + { id: "openai/gpt-oss-120b", name: "Gpt Oss 120B" }, + { id: "qwen/qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5 122B A10B" }, + { id: "qwen/qwen3.5-35b-a3b", name: "Qwen3.5 35B A3B" }, + { id: "qwen/qwen3.5-397b-a17b", name: "Qwen3.5 397B A17B" }, + { id: "qwen/qwen3.5-9b", name: "Qwen3.5 9B" }, + { id: "qwen/qwen3.5-flash", name: "Qwen3.5 Flash" }, + { id: "qwen/qwen3.5-plus-02-15", name: "Qwen3.5 Plus 02 15" }, + { id: "qwen/qwen3.6-plus", name: "Qwen3.6 Plus" }, + { id: "qwen/qwen3.7-max", name: "Qwen3.7 Max" }, + { id: "qwen/qwen3.7-plus", name: "Qwen3.7 Plus" }, + { id: "qwen/qwen3.8-max", name: "Qwen3.8 Max" }, + { id: "qwen3.5-omni-plus", name: "Qwen3.5 Omni Plus" }, + { id: "qwen3.6-flash", name: "Qwen3.6 Flash" }, + { id: "sakana/fugu-ultra", name: "Fugu Ultra" }, + { id: "seed-2-0-code-preview-260328", name: "Seed 2 0 Code Preview 260328" }, + { id: "seed-2-0-lite-260428", name: "Seed 2 0 Lite 260428" }, + { id: "seed-2-0-mini-260428", name: "Seed 2 0 Mini 260428" }, + { id: "seed-2-0-pro-260328", name: "Seed 2 0 Pro 260328" }, + { id: "stepfun/step-3.5-flash", name: "Step 3.5 Flash" }, + { id: "stepfun/step-3.7-flash", name: "Step 3.7 Flash" }, + { id: "tencent/hy3-preview", name: "Hy3 Preview" }, + { id: "x-ai/grok-4.1-fast", name: "Grok 4.1 Fast" }, + { id: "x-ai/grok-4.20-beta", name: "Grok 4.20 Beta" }, + { id: "x-ai/grok-4.3", name: "Grok 4.3" }, + { id: "x-ai/grok-4.5", name: "Grok 4.5" }, + { id: "x-ai/grok-build-0.1", name: "Grok Build 0.1" }, + { id: "xiaomi/mimo-v2-flash", name: "Mimo V2 Flash" }, + { id: "xiaomi/mimo-v2-omni", name: "Mimo V2 Omni" }, + { id: "xiaomi/mimo-v2-pro", name: "Mimo V2 Pro" }, + { id: "xiaomi/mimo-v2.5", name: "Mimo V2.5" }, + { id: "xiaomi/mimo-v2.5-pro", name: "Mimo V2.5 Pro" }, + { id: "z-ai/glm-4.5-air", name: "Glm 4.5 Air" }, + { id: "z-ai/glm-4.6", name: "Glm 4.6" }, + { id: "z-ai/glm-4.6v", name: "Glm 4.6V" }, + { id: "z-ai/glm-4.7", name: "Glm 4.7" }, + { id: "z-ai/glm-5", name: "Glm 5" }, + { id: "z-ai/glm-5-turbo", name: "Glm 5 Turbo" }, + { id: "z-ai/glm-5.1", name: "Glm 5.1" }, + { id: "z-ai/glm-5.2", name: "Glm 5.2" }, + ], + serviceKinds: ["llm", "embedding", "image"], + embeddingConfig: { + baseUrl: "https://api.tokenrouter.com/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + }, + imageConfig: { + baseUrl: "https://api.tokenrouter.com/v1/images/generations", + }, + modelsFetcher: { url: "https://api.tokenrouter.com/v1/models", type: "openai" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js new file mode 100644 index 00000000..2b4ace60 --- /dev/null +++ b/open-sse/providers/registry/trae.js @@ -0,0 +1,76 @@ +// Trae (ByteDance marscode) provider registry entry. +// Chat = SOLO remote agent API: +// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} +// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE +// Auth: Authorization: Cloud-IDE-JWT +export default { + id: "trae", + alias: "tr", + uiAlias: "tr", + aliases: ["marscode"], + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth"], + display: { + name: "Trae", + icon: "bolt", + color: "#FF6A00", + textIcon: "TR", + website: "https://www.trae.ai", + notice: { signupUrl: "https://www.trae.ai" }, + }, + transport: { + // SOLO remote agent base — verified working chat endpoint. + baseUrl: "https://core-normal.trae.ai/api/remote/v1", + format: "openai", + headers: { + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "Referer": "https://solo.trae.ai/", + }, + // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. + auth: { + combined: true, + header: "Authorization", + scheme: "Cloud-IDE-JWT", + }, + usage: { + url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + }, + regions: { + cn: "https://api.marscode.com", + sg: "https://api.trae.ai", + us: "https://www.trae.ai", + }, + defaultRegion: "cn", + }, + oauth: { + clientId: "ono9krqynydwx5", + clientSecret: "-", + platform: "trae", + pollInterval: 1500, + // Login guidance returns LoginHost for browser open. + loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + // ExchangeToken: refresh -> access (POST JSON, body below). + tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. + refresh: { encoding: "json" }, + }, + // Model catalog (IDE flow, core-normal.trae.ai). + models: [ + { id: "auto", name: "Auto (Server Picks)" }, + { id: "work", name: "Work (Fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { 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" }, + ], + features: { usage: true }, +}; diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js new file mode 100644 index 00000000..f0b23fa8 --- /dev/null +++ b/open-sse/providers/registry/windsurf.js @@ -0,0 +1,143 @@ +// Windsurf provider registry — Firebase+Codeium+Devin auth chain. +// Chat = Codeium gRPC-web protobuf: +// POST {base} Content-Type: application/grpc-web+proto +// Service: exa.language_server_pb.LanguageServerService / GetChatMessage +export default { + id: "windsurf", + alias: "ws", + uiAlias: "ws", + display: { + name: "Windsurf", + icon: "surfing", + color: "#14B8A6", + website: "https://windsurf.com", + notice: { signupUrl: "https://windsurf.com" }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth", "apikey"], + + transport: { + baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + format: "openai", + headers: { + "Content-Type": "application/grpc-web+proto", + "Accept": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + // apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key. + auth: { combined: true, header: "Authorization", scheme: "Bearer" }, + }, + + // Auth chain (4 terminal paths, all yield apiKey): + // 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} + // 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus) + // 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1 + // 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com + oauth: { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser", + apiServerUrl: "https://server.codeium.com", + auth1ApiServerUrl: "https://server.self-serve.windsurf.com", + platform: "windsurf", + // Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus, + // headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:, + // body = field1:session_token, field2:varint 1. + quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + }, + + // Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x). + // Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid. + // contextLength dropped — 9router schema uses id+name only. + models: [ + // Cognition / SWE + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + // Claude Opus 4.7 — effort-tiered + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" }, + { id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" }, + { id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" }, + // Claude Sonnet/Opus 4.6 + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" }, + { id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, + // Claude 4.5 + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // GPT-5.5 — effort-tiered + { id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" }, + { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, + { id: "gpt-5.5-high", name: "GPT-5.5 High" }, + { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium" }, + { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low" }, + { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, + { id: "gpt-5.5-none", name: "GPT-5.5 None" }, + // GPT-5.4 — effort-tiered + { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" }, + { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium" }, + { id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" }, + { id: "gpt-5.4-none", name: "GPT-5.4 None" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" }, + { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, + { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" }, + { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, + // GPT-5.3 Codex + { id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" }, + { id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, + { id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" }, + { id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, + // GPT-5.2 / 5 + { id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" }, + { id: "gpt-5.2-high", name: "GPT-5.2 High" }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium" }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, + { id: "gpt-5.2-none", name: "GPT-5.2 None" }, + { id: "gpt-5", name: "GPT-5" }, + // GPT-4.1 / 4o + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + // Gemini + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" }, + { id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" }, + { id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" }, + { id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + // Others + { id: "deepseek-v4", name: "DeepSeek V4" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5.1", name: "GLM-5.1" }, + ], +}; diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index efe13bdd..53a73c07 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -32,9 +32,13 @@ export default { { id: "grok-code-fast-1", name: "Grok Code Fast" }, { id: "grok-3", name: "Grok 3" }, { id: "grok-2-image-1212", name: "Grok 2 Image", params: ["n","response_format"], kind: "image" }, + { id: "grok-imagine-video", name: "Grok Imagine Video", params: ["duration","aspect_ratio","resolution"], kind: "video" }, ], - serviceKinds: ["llm","imageToText","webSearch","image"], + serviceKinds: ["llm","imageToText","webSearch","image","video"], imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] }, + // Async video jobs (POST returns { request_id }, GET polls until done/failed). + // Docs: https://docs.x.ai/developers/rest-api-reference/inference/videos + videoConfig: { baseUrl: "https://api.x.ai/v1/videos" }, searchViaChat: { defaultModel: "grok-4.20-reasoning", endpoint: "https://api.x.ai/v1/responses", diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js index fcef7af8..49465f43 100644 --- a/open-sse/providers/registry/xiaomi-mimo.js +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -15,10 +15,11 @@ export default { textIcon: "XM", website: "https://xiaomimimo.com", notice: { - apiKeyUrl: "https://xiaomimimo.com", + apiKeyUrl: "https://platform.xiaomimimo.com/console/api-keys", }, }, category: "apikey", + serviceKinds: ["llm", "tts"], transport: { baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", validateUrl: "https://api.xiaomimimo.com/v1/models", @@ -42,5 +43,12 @@ export default { { id: "mimo-v2.5", name: "MiMo V2.5" }, { id: "mimo-v2-omni", name: "MiMo V2 Omni" }, { id: "mimo-v2-flash", name: "MiMo V2 Flash" }, + { id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS", kind: "tts" }, ], + ttsConfig: { + baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + format: "xiaomi-mimo-tts", + }, }; diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js new file mode 100644 index 00000000..9224cf95 --- /dev/null +++ b/open-sse/providers/registry/zed.js @@ -0,0 +1,71 @@ +// Zed provider — RSA keypair callback auth (NOT standard OAuth). +export default { + id: "zed", + priority: 10, + alias: "zd", + uiAlias: "zd", + hidden: true, + display: { + name: "Zed", + icon: "code", + color: "#A855F7", + website: "https://zed.dev", + notice: { + signupUrl: "https://zed.dev/native_app_signin", + }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + + transport: { + // Zed hosted LLM aggregator: cloud.zed.dev/completions is a + // multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model. + // Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer + // token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth). + baseUrl: "https://cloud.zed.dev/completions", + format: "openai", + forceStream: true, + headers: { + "content-type": "application/json", + }, + // Auth scheme is non-standard: "Authorization: " plus a duplicate + // x-zed-cloud-token header (verified in zed_account.rs build_authorization_header + + // cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling. + auth: { + combined: true, + header: "Authorization", + scheme: " ", // placeholder — real value built in executor + }, + usage: { + url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs + }, + // Live catalog discovery — Zed's hosted model list changes frequently and is fetched + // per-connection rather than hardcoded. + modelsUrl: "https://cloud.zed.dev/models", + }, + + // Empty static catalog + passthrough: Zed fronts a rotating set of upstream models + // (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is + // forwarded as-is rather than validated against a frozen list. + models: [], + passthroughModels: true, + + oauth: { + // Zed auth flow is RSA-based, NOT OAuth2/PKCE: + // 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64). + // 2. Bind random TCP port on 127.0.0.1. + // 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}. + // 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=... + // where access_token = base64(RSA-encrypted plaintext token). + // 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token. + // No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh. + authorizeUrl: "https://zed.dev/native_app_signin", + platform: "zed", + rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener. + }, + + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 32388584..fdd76c40 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -47,6 +47,26 @@ export const CLAUDE_CLI_SPOOF_HEADERS = { "X-Stainless-Timeout": "600" }; +const ANTHROPIC_BETA_BASE = [ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", + "structured-outputs-2025-12-15", + "fast-mode-2026-02-01", + "redact-thinking-2026-02-12", + "token-efficient-tools-2026-03-28", +]; +const ANTHROPIC_BETA_HEAVY_AGENT = ["advanced-tool-use-2025-11-20", "effort-2025-11-24"]; + +// Heavy-agent beta flags are gated to opus/sonnet — cheaper models don't need them. +export function selectAnthropicBeta(model = "") { + const flags = [...ANTHROPIC_BETA_BASE]; + if (/^claude-(opus|sonnet)/.test(model)) flags.push(...ANTHROPIC_BETA_HEAVY_AGENT); + return flags.join(","); +} + // Shared baseUrls export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; @@ -54,6 +74,13 @@ export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; export const OPENAI_COMPAT_BASE = "https://api.openai.com/v1"; export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; +// Official Antigravity IDE Desktop 2.1.1 fingerprint captured from macOS arm64. +// Keep this static even when 9router runs on Linux: the provider profile is +// intentionally matching the IDE client, not the server host. +export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; +export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; + // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) export const ANTIGRAVITY_OAUTH_CLIENT = { clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js new file mode 100644 index 00000000..f42cdd84 --- /dev/null +++ b/open-sse/providers/thinkingLevels.js @@ -0,0 +1,55 @@ +// Resolve valid thinking levels per model — drives UI level picker (suffix "model(level)"). +// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). +import { getCapabilitiesForModel } from "./capabilities.js"; +import { matchPattern } from "./pricing.js"; +import { resolveKiroEffortPath } from "../config/kiroConstants.js"; + +// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. +const L = { + base: ["none", "low", "medium", "high"], // qwen, step, hunyuan, gemini-budget + onOff: ["none", "thinking"], // zai (binary), minimax (adaptive) + openai: ["none", "minimal", "low", "medium", "high", "xhigh"], // GPT-5.x / o-series (no "max") + levelMax: ["none", "low", "medium", "high", "max"], // claude-adaptive, kimi + budgetX: ["none", "low", "medium", "high", "xhigh", "max"], // claude-budget + gemini: ["minimal", "low", "medium", "high"], // gemini-3 thinkingLevel (no disable) + hiMax: ["none", "high", "max"], // deepseek (low/med→high, xhigh→max) +}; + +// thinkingFormat → valid selectable levels (source of truth for UI options). +const FORMAT_LEVELS = { + openai: L.openai, + "claude-adaptive": L.levelMax, + "claude-budget": L.budgetX, + "gemini-level": L.gemini, + "gemini-budget": L.base, + zai: L.onOff, + qwen: L.base, + kimi: L.levelMax, + deepseek: L.hiMax, + minimax: L.onOff, + hunyuan: L.base, + step: L.base, +}; + +const CODEX_GPT_5_6_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +// Model-name pattern overrides (glob, first match wins) — more precise than format default. +const PATTERN_THINKING = [ + { provider: "codex", pattern: "*gpt-5.6-sol*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, + { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking +]; + +// Returns valid thinking levels for a model, or null when the model has no reasoning. +export function getThinkingLevels(provider, model) { + if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; + const caps = getCapabilitiesForModel(provider, model); + if (!caps.reasoning) return null; + const hit = PATTERN_THINKING.find((entry) => + (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) + ); + let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; + if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); + return levels; +} diff --git a/open-sse/rtk/autodetect.js b/open-sse/rtk/autodetect.js index 99ab6a77..81992034 100644 --- a/open-sse/rtk/autodetect.js +++ b/open-sse/rtk/autodetect.js @@ -1,9 +1,10 @@ // Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras -// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list -// → read-numbered → dedup-log → smart-truncate → null +// Detection order: git-log → git-diff → git-status → build-output → grep → find → tree → ls → search-list +// → read-numbered → dedup-log → smart-truncate → null import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js"; import { gitDiff } from "./filters/gitDiff.js"; import { gitStatus } from "./filters/gitStatus.js"; +import { gitLog } from "./filters/gitLog.js"; import { buildOutput } from "./filters/buildOutput.js"; import { grep } from "./filters/grep.js"; import { find } from "./filters/find.js"; @@ -17,6 +18,7 @@ import { searchList, SEARCH_LIST_HEADER_RE } from "./filters/searchList.js"; const RE_GIT_DIFF = /^diff --git /m; const RE_GIT_DIFF_HUNK = /^@@ /m; const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m; +const RE_GIT_LOG = /^[*|/\\ ]*commit [0-9a-f]{7,40}$/m; const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m; const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im; const RE_TREE_GLYPH = /[├└]──|│ /; @@ -27,6 +29,7 @@ export function autoDetectFilter(text) { // Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text; + if (RE_GIT_LOG.test(head)) return gitLog; if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff; if (RE_GIT_STATUS.test(head)) return gitStatus; @@ -81,6 +84,11 @@ function isGrepLine(line) { function isPathLike(line) { const t = line.trim(); if (t.length === 0) return false; + // A drive-letter prefix (e.g. "C:\Users\me" or "C:/Users/me") marks a + // Windows absolute path, so treat the whole line as path-like. Trailing + // colons (e.g. "C:\path\file.js:10") are tolerated, matching grep-style + // suffixes on Windows dumps. + if (/^[A-Za-z]:[\\/]/.test(t)) return true; if (t.includes(":")) return false; return t.startsWith(".") || t.startsWith("/") || t.includes("/"); } diff --git a/open-sse/rtk/cavemanPrompts.js b/open-sse/rtk/cavemanPrompts.js index 0b6f6f57..7b533d82 100644 --- a/open-sse/rtk/cavemanPrompts.js +++ b/open-sse/rtk/cavemanPrompts.js @@ -18,6 +18,14 @@ const SHARED_AUTO_CLARITY = "Auto-Clarity: drop caveman for security warnings, i const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure."; +const SHARED_NO_INVENTED_ABBREV = "No invented abbreviations. Standard well-known tech acronyms (DB, API, HTTP, URL, JSON, ID, OS, CPU) OK. Names of code symbols, function names, API names, error strings: keep verbatim."; + +const SHARED_PRESERVE_LANGUAGE = "Preserve the user's dominant language. User wrote Vietnamese, reply Vietnamese. User wrote English, reply English. Wenyan/classical-Chinese levels override this language-preservation rule. Code identifiers, error strings, file paths, commands: keep in their original form regardless of language."; + +const SHARED_NO_SELF_REFERENCE = 'No self-reference. Do not name or announce the style (no "caveman mode", no "me caveman think", no "compressed mode active"). Just respond.'; + +const SHARED_NO_DECORATION = 'No decorative emoji. No narrating tool calls ("I will now search", "I used X to find Y"). No status phrases ("Sure!", "Of course!", "I\'d be happy to"). No causal arrow shorthand ("A -> B -> fails"). State the thing, the action, the reason. Then next step.'; + export const CAVEMAN_PROMPTS = { [CAVEMAN_LEVELS.LITE]: [ "Respond tersely. Keep grammar and full sentences but drop filler, hedging and pleasantries (just/really/basically/sure/of course/I'd be happy to).", @@ -26,6 +34,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.FULL]: [ @@ -36,16 +48,24 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.ULTRA]: [ "Respond ultra-terse. Maximum compression. Telegraphic.", - "Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, use arrows for causality (X → Y). One word when one word enough.", - "Pattern: [thing] → [result]. [fix].", + "Strip conjunctions. One word when one word enough.", + "Pattern: [thing] [action] [reason]. [next step].", SHARED_EXAMPLES, SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN_LITE]: [ @@ -55,6 +75,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN]: [ @@ -65,6 +89,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN_ULTRA]: [ @@ -74,5 +102,9 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), }; diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js index 752c2fee..bc80c23a 100644 --- a/open-sse/rtk/constants.js +++ b/open-sse/rtk/constants.js @@ -4,6 +4,7 @@ export const MIN_COMPRESS_SIZE = 500; // bytes; skip tiny blobs export const DETECT_WINDOW = 1024; // autodetect peeks first N chars export const GIT_DIFF_HUNK_MAX_LINES = 100; // per-hunk line cap export const GIT_DIFF_CONTEXT_KEEP = 3; // context lines around changes +export const GIT_LOG_MAX_LINES = 200; // gitLog line cap export const DEDUP_LINE_MAX = 2000; // dedupLog truncation cap // Rust pipe_cmd.rs parity caps diff --git a/open-sse/rtk/filters/find.js b/open-sse/rtk/filters/find.js index 5770a99b..4b64169b 100644 --- a/open-sse/rtk/filters/find.js +++ b/open-sse/rtk/filters/find.js @@ -9,16 +9,17 @@ export function find(input) { const byDir = new Map(); for (const path of lines) { - const lastSlash = path.lastIndexOf("/"); + // Accept both Unix ("/a/b") and Windows ("C:\a\b") separators + const lastSep = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); let dir; let basename; - if (lastSlash === -1) { + if (lastSep === -1) { dir = "."; basename = path; } else { // Rust: PathBuf::from(path).parent().display() + file_name().display() - dir = path.slice(0, lastSlash) || "/"; - basename = path.slice(lastSlash + 1); + dir = path.slice(0, lastSep) || "/"; + basename = path.slice(lastSep + 1); } if (!byDir.has(dir)) byDir.set(dir, []); byDir.get(dir).push(basename); @@ -31,7 +32,8 @@ export function find(input) { const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX); for (const dir of showDirs) { const files = byDir.get(dir); - out += `${dir}/ (${files.length})\n`; + const dirLabel = dir.replace(/\\/g, "/"); + out += `${dirLabel}/ (${files.length})\n`; const showFiles = files.slice(0, FIND_PER_DIR_MAX); for (const f of showFiles) out += ` ${f}\n`; if (files.length > FIND_PER_DIR_MAX) { diff --git a/open-sse/rtk/filters/gitLog.js b/open-sse/rtk/filters/gitLog.js new file mode 100644 index 00000000..9769c6de --- /dev/null +++ b/open-sse/rtk/filters/gitLog.js @@ -0,0 +1,99 @@ +// JS-native git-log filter +// Compresses `git log` output: keeps commit headers, subjects, Author/Date; +// drops body padding, decoration, embedded diff lines. +import { GIT_LOG_MAX_LINES } from "../constants.js"; + +export function gitLog(text, maxLines = GIT_LOG_MAX_LINES) { + if (!text) return ""; + + const input = String(text); + const lines = input.split("\n"); + const out = []; + let skipped = 0; + let inCommit = false; + let subjectSeen = false; + + function pushLine(l) { + if (out.length < maxLines) { + out.push(l); + return true; + } + skipped++; + return false; + } + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const line = raw.trimEnd(); + const trimmed = line.trim(); + + // commit header — starts new commit entry + // Also matched with leading graph decoration (`* commit abc1234...` — --graph without --oneline) + if (/^commit [0-9a-f]{7,40}$/i.test(trimmed) || /^[*|/\\ ]+commit [0-9a-f]{7,40}/i.test(trimmed)) { + inCommit = true; + subjectSeen = false; + pushLine(line); + continue; + } + + if (inCommit) { + // Author / Date — keep as-is (already column 0 in raw, or graph-prefix stripped by commit-header match) + if (/^[*|/\\ ]*(Author|Date):/i.test(trimmed)) { + pushLine(trimmed); + continue; + } + // blank — skip + if (trimmed === "") continue; + // indented subject (4 spaces, optionally preceded by graph decoration) — first one is subject + if (!subjectSeen && /^[*|/\\ ]* \S/.test(line)) { + pushLine(" Subject: " + trimmed); + subjectSeen = true; + continue; + } + // stat summary: "N file(s) changed, N insertions(+), N deletions(-)" + if (/^\d+ file\w* changed/.test(trimmed)) { + pushLine(" " + trimmed); + continue; + } + // embedded diff header — one-line marker + if (/^diff --git /.test(trimmed)) { + pushLine(" ... diff body omitted"); + continue; + } + // everything else in commit body — drop + continue; + } + + // Not in a commit block (--oneline / --graph modes): + + // Graph decoration + sha + subject: "*|/\\ " + const graphMatch = trimmed.match(/^[*|/\\ ]+([0-9a-f]{7,40}\s+.+)/i); + if (graphMatch) { + pushLine(graphMatch[1]); + continue; + } + + // Plain oneline: " " + if (/^[0-9a-f]{7,40}\s+/.test(trimmed)) { + pushLine(trimmed); + continue; + } + + // Pure graph decoration (no sha) — drop + if (/^[*|/\\ ]+$/.test(trimmed) && /[*|/\\]/.test(trimmed)) { + continue; + } + + // catch-all pass-through + pushLine(trimmed); + } + + if (skipped > 0) out.push(`... (${skipped} more lines)`); + + const result = out.join("\n"); + if (!result && input) return input; + if (result.length > input.length) return input; + return result; +} + +gitLog.filterName = "git-log"; diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js index 8f3b1f33..2b15eed2 100644 --- a/open-sse/rtk/headroom.js +++ b/open-sse/rtk/headroom.js @@ -18,14 +18,24 @@ function jsonBytes(value) { function messagePayload(body) { if (Array.isArray(body?.messages)) return body.messages; if (Array.isArray(body?.input)) return body.input; + const kiro = collectKiroHeadroomMessages(body); + if (kiro) return kiro.messages; return null; } function captureSizeSnapshot(body) { const messages = messagePayload(body); + const toolHistory = messages?.filter((message) => + message?.role === "tool" + || message?.role === "function" + || message?.tool_calls?.length + || message?.content?.some?.((part) => part?.type === "tool_use" || part?.type === "tool_result") + ) || []; return { bodyBytes: jsonBytes(body), messageBytes: messages ? jsonBytes(messages) : 0, + toolSchemaBytes: jsonBytes(body?.tools || []), + toolHistoryBytes: jsonBytes(toolHistory), }; } @@ -81,6 +91,121 @@ function hasUnsafeResponsesInputForCompression(body) { }); } +function collectKiroHeadroomMessages(body) { + const state = body?.conversationState; + if (!state || typeof state !== "object") return null; + + const messages = []; + const targets = []; + + const addTextTarget = (role, text, target, extra = {}) => { + if (typeof text !== "string") return; + messages.push({ role, content: text, ...extra }); + targets.push(target); + }; + + const toToolCalls = (toolUses) => { + if (!Array.isArray(toolUses) || toolUses.length === 0) return undefined; + const calls = toolUses.map((toolUse) => ({ + id: toolUse?.toolUseId, + type: "function", + function: { + name: toolUse?.name || "", + arguments: JSON.stringify(toolUse?.input || {}), + }, + })).filter((call) => call.id || call.function.name); + return calls.length > 0 ? calls : undefined; + }; + + const visit = (item) => { + const user = item?.userInputMessage; + if (user) { + addTextTarget("system", user.systemInstruction, { object: user, key: "systemInstruction" }); + addTextTarget("user", user.content, { object: user, key: "content" }); + + const toolResults = user.userInputMessageContext?.toolResults; + if (Array.isArray(toolResults)) { + for (const toolResult of toolResults) { + const content = toolResult?.content; + if (!Array.isArray(content)) continue; + for (const part of content) { + addTextTarget( + "tool", + part?.text, + { object: part, key: "text" }, + toolResult?.toolUseId ? { tool_call_id: toolResult.toolUseId } : {} + ); + } + } + } + return; + } + + const assistant = item?.assistantResponseMessage; + if (assistant) { + const toolCalls = toToolCalls(assistant.toolUses); + addTextTarget( + "assistant", + assistant.content, + { object: assistant, key: "content" }, + toolCalls ? { tool_calls: toolCalls } : {} + ); + } + }; + + if (Array.isArray(state.history)) { + for (const item of state.history) visit(item); + } + if (state.currentMessage) visit(state.currentMessage); + + return messages.length > 0 ? { messages, targets } : null; +} + +function textFromHeadroomMessage(message) { + const content = message?.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + + const parts = []; + for (const part of content) { + if (typeof part === "string") { + parts.push(part); + } else if (typeof part?.text === "string") { + parts.push(part.text); + } + } + return parts.length > 0 ? parts.join("\n") : null; +} + +function applyKiroHeadroomMessages(projection, compressedMessages, diagnostics) { + if (!Array.isArray(compressedMessages) || compressedMessages.length !== projection.messages.length) { + setDiagnostic(diagnostics, "proxy response did not match Kiro message count"); + return false; + } + + const updates = []; + for (let i = 0; i < projection.messages.length; i++) { + const expected = projection.messages[i]; + const actual = compressedMessages[i]; + if (!actual || actual.role !== expected.role) { + setDiagnostic(diagnostics, "proxy response did not preserve Kiro message order"); + return false; + } + + const text = textFromHeadroomMessage(actual); + if (text === null) { + setDiagnostic(diagnostics, "proxy response missing Kiro text content"); + return false; + } + updates.push({ target: projection.targets[i], text }); + } + + for (const update of updates) { + update.target.object[update.target.key] = update.text; + } + return true; +} + // POST messages to Headroom /v1/compress; returns compressed messages + stats or null. async function callCompress(url, messages, model, timeoutMs, compressUserMessages, diagnostics) { const endpoint = buildCompressEndpoint(url); @@ -171,6 +296,22 @@ export async function compressWithHeadroom(body, { enabled, url, model, format, return data; } + // Kiro shape: conversationState.history/currentMessage are projected to + // OpenAI messages for the proxy, then copied back into the original Kiro + // fields. Keep the provider payload shape intact for Kiro's executor. + if (format === "kiro") { + const projection = collectKiroHeadroomMessages(body); + if (!projection) { + setDiagnostic(diagnostics, "Kiro request did not project to messages[]"); + return null; + } + const data = await callCompress(url, projection.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); + if (!data) return null; + if (!applyKiroHeadroomMessages(projection, data.messages, diagnostics)) return null; + if (diagnostics) diagnostics.after = captureSizeSnapshot(body); + return data; + } + // OpenAI shape: messages/input go straight to the proxy. const key = Array.isArray(body.messages) ? "messages" : Array.isArray(body.input) ? "input" @@ -203,7 +344,10 @@ export function formatHeadroomSizeLog(diagnostics) { const before = diagnostics?.before; const after = diagnostics?.after; if (!before || !after) return ""; - return `body=${before.bodyBytes}B→${after.bodyBytes}B messages=${before.messageBytes}B→${after.messageBytes}B`; + const effective = before.bodyBytes > 0 + ? (((before.bodyBytes - after.bodyBytes) / before.bodyBytes) * 100).toFixed(1) + : "0.0"; + return `body=${before.bodyBytes}B→${after.bodyBytes}B messages=${before.messageBytes}B→${after.messageBytes}B tools=${before.toolSchemaBytes || 0}B→${after.toolSchemaBytes || 0}B toolHistory=${before.toolHistoryBytes || 0}B→${after.toolHistoryBytes || 0}B effective=${effective}%`; } export function isHeadroomPhantomSavings(stats, diagnostics, minShrinkRatio = 0.05) { diff --git a/open-sse/rtk/pxpipe.js b/open-sse/rtk/pxpipe.js new file mode 100644 index 00000000..04ac8f45 --- /dev/null +++ b/open-sse/rtk/pxpipe.js @@ -0,0 +1,104 @@ +// PXPIPE: render bulky Claude-format context as dense PNGs via pxpipe-proxy's +// library API (transformAnthropicMessages). Fail-open like every token saver: +// any error/timeout returns { body: null, summary } and leaves the request untouched. +import { FORMATS } from "../translator/formats.js"; + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_MIN_CHARS = 25000; +// pxpipe's own profitability gate assumes ~4 chars/token; reuse it for the +// estimated before/after numbers surfaced in stats (marked "estimated" in UI). +const EST_CHARS_PER_TOKEN = 4; + +function bodyChars(body) { + try { + return JSON.stringify(body)?.length || 0; + } catch { + return 0; + } +} + +function estTokens(chars) { + return Math.round(chars / EST_CHARS_PER_TOKEN); +} + +function skipped(reason, extra = {}) { + return { body: null, summary: { applied: false, reason, ...extra } }; +} + +// Transform a Claude-format request body through pxpipe. Returns +// { body: | null, summary } — body is null when nothing changed. +// opts.transform is injected by the host (src side) so open-sse stays free of +// filesystem/install concerns and remains usable standalone. +export async function compressWithPxpipe(body, { enabled, format, model, minChars, timeoutMs, transform } = {}) { + if (!enabled) return skipped("disabled"); + if (typeof transform !== "function") return skipped("not_installed"); + if (!body) return skipped("missing_body"); + if (format !== FORMATS.CLAUDE) return skipped("unsupported_format", { detail: format }); + + const startedAt = Date.now(); + const originalChars = bodyChars(body); + const threshold = Number(minChars) > 0 ? Number(minChars) : DEFAULT_MIN_CHARS; + if (originalChars < threshold) { + return skipped("below_threshold", { originalChars, threshold }); + } + + try { + const encoded = new TextEncoder().encode(JSON.stringify(body)); + const budget = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS; + // transformAnthropicMessages is local CPU work and can't be aborted; race a + // timer and discard the result if it loses (input body is never mutated). + const result = await Promise.race([ + transform({ + body: encoded, + model, + options: { minCompressChars: threshold }, + }), + new Promise((resolve) => setTimeout(() => resolve(null), budget)), + ]); + if (!result) return skipped("timeout", { originalChars, durationMs: Date.now() - startedAt }); + if (!result.applied) { + return skipped(result.reason || "passthrough", { + detail: result.detail, + originalChars, + durationMs: Date.now() - startedAt, + }); + } + + const newBody = JSON.parse(new TextDecoder().decode(result.body)); + const compressedBodyChars = bodyChars(newBody); + const info = result.info || {}; + const imagedChars = info.compressedChars || 0; + // The transformed body is BIGGER in bytes (base64 PNGs) but cheaper in tokens: + // images bill by pixels (Anthropic: pixels/750), not by encoded length. So the + // after-estimate is remaining-text tokens + image tokens — never chars/4 of the + // new body. Provider-billed usage recorded per request stays the ground truth. + const imageTokensEst = info.imageTokens + || (info.imagePixels ? Math.round(info.imagePixels / 750) : (info.imageCount || 0) * 4761); + const summary = { + applied: true, + reason: "applied", + originalChars, + compressedBodyChars, + imagedChars, + imageCount: info.imageCount || 0, + imageBytes: info.imageBytes || 0, + tokensBeforeEst: info.baselineTokens || estTokens(originalChars), + tokensAfterEst: estTokens(Math.max(0, originalChars - imagedChars)) + imageTokensEst, + durationMs: Date.now() - startedAt, + cacheOwnsControl: result.cache?.ownsCacheControl === true, + }; + summary.tokensSavedEst = Math.max(0, summary.tokensBeforeEst - summary.tokensAfterEst); + summary.savedPct = summary.tokensBeforeEst > 0 + ? +((summary.tokensSavedEst / summary.tokensBeforeEst) * 100).toFixed(2) + : 0; + return { body: newBody, summary }; + } catch (e) { + return skipped("transform_error", { detail: e?.message || String(e), originalChars, durationMs: Date.now() - startedAt }); + } +} + +export function formatPxpipeLog(summary) { + if (!summary) return null; + if (!summary.applied) return null; + return `imaged ${summary.imagedChars}ch → ${summary.imageCount} image(s) | est ${summary.tokensBeforeEst}→${summary.tokensAfterEst} tokens (-${summary.savedPct}%) | ${summary.durationMs}ms`; +} diff --git a/open-sse/rtk/registry.js b/open-sse/rtk/registry.js index d9d9bf56..5378aabd 100644 --- a/open-sse/rtk/registry.js +++ b/open-sse/rtk/registry.js @@ -1,6 +1,7 @@ import { FILTERS } from "./constants.js"; import { gitDiff } from "./filters/gitDiff.js"; import { gitStatus } from "./filters/gitStatus.js"; +import { gitLog } from "./filters/gitLog.js"; import { grep } from "./filters/grep.js"; import { find } from "./filters/find.js"; import { dedupLog } from "./filters/dedupLog.js"; @@ -13,6 +14,7 @@ import { searchList } from "./filters/searchList.js"; const REGISTRY = { [FILTERS.GIT_DIFF]: gitDiff, [FILTERS.GIT_STATUS]: gitStatus, + [FILTERS.GIT_LOG]: gitLog, [FILTERS.GREP]: grep, [FILTERS.FIND]: find, [FILTERS.DEDUP_LOG]: dedupLog, diff --git a/open-sse/services/capacityAdapter.js b/open-sse/services/capacityAdapter.js new file mode 100644 index 00000000..7b096f97 --- /dev/null +++ b/open-sse/services/capacityAdapter.js @@ -0,0 +1,173 @@ +/** + * Capacity Adapter — global fallback pools of models per input-modality capability + * (vision / pdf / audioInput / videoInput). + * + * The pool models are appended as extra fallback candidates behind whatever models + * were already going to be tried (a combo's members, or a single target model). + * combo.js's existing reorderByCapabilities then floats a capable pool model to the + * front only when none of the original models can handle the request — so this + * never overrides a combo that already has a member covering the capability. + */ +import { getCapabilitiesForModel } from "../providers/capabilities.js"; + +const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; +const HARD_CAPS = new Set(CAPABILITY_KEYS); +const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free"; + +// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat: +// accept the legacy array form [{model, enabled}] (treated as enabled, fallback). +function normalizeCapEntry(entry) { + if (Array.isArray(entry)) { + return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) }; + } + if (entry && typeof entry === "object") { + return { + enabled: entry.enabled !== false, + roundRobin: !!entry.roundRobin, + models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [], + }; + } + return { enabled: false, roundRobin: false, models: [] }; +} + +// Resolve one capability's full config. Enabled pools with no models fall back +// to DEFAULT_FALLBACK_MODEL so the toggle is never a no-op. +export function getCapacityAdapterConfig(cap, settings) { + const entry = normalizeCapEntry(settings?.capacityAdapter?.[cap]); + if (entry.enabled && entry.models.length === 0) { + return { ...entry, models: [DEFAULT_FALLBACK_MODEL] }; + } + return entry; +} + +// Flatten enabled models across all capability pools, in priority order, deduped. +export function getCapacityAdapterModels(settings) { + const seen = new Set(); + const models = []; + for (const cap of CAPABILITY_KEYS) { + const { enabled, models: pool } = getCapacityAdapterConfig(cap, settings); + if (!enabled) continue; + for (const m of pool) { + if (!seen.has(m)) { + seen.add(m); + models.push(m); + } + } + } + return models; +} + +// Strategy for a capability: "round-robin" when enabled+roundRobin, else "fallback". +export function getCapacityAdapterStrategy(cap, settings) { + const { enabled, roundRobin } = getCapacityAdapterConfig(cap, settings); + return enabled && roundRobin ? "round-robin" : "fallback"; +} + +// Strategy from the request's required capabilities: picks the first capability +// whose adapter pool is enabled and can satisfy a hard requirement. +export function getActiveAdapterStrategy(requiredCapabilities, settings) { + const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c)); + for (const cap of hard) { + const { enabled, models } = getCapacityAdapterConfig(cap, settings); + if (!enabled || models.length === 0) continue; + return getCapacityAdapterStrategy(cap, settings); + } + return "fallback"; +} + +function modelSatisfies(modelStr, requiredHard) { + const slash = modelStr.indexOf("/"); + const provider = slash > 0 ? modelStr.slice(0, slash) : ""; + const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr; + const caps = getCapabilitiesForModel(provider, model); + return requiredHard.every((c) => caps[c] === true); +} + +// Prepend capacity-adapter models as priority candidates when NONE of the +// original models (combo members, or the single target model) can satisfy the +// request's required capabilities. Adapter models go FIRST (priority); the +// original models follow as fallback. Leaves `models` untouched when the +// original list already covers it (combo.js's reorderByCapabilities handles +// that case via autoSwitch). +export function augmentModelsWithCapacityAdapter(models, requiredCapabilities, settings) { + const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c)); + if (hard.length === 0 || !Array.isArray(models) || models.length === 0) return models; + if (models.some((m) => modelSatisfies(m, hard))) return models; + + const pool = getCapacityAdapterModels(settings).filter((m) => !models.includes(m) && modelSatisfies(m, hard)); + if (pool.length === 0) return models; + return [...pool, ...models]; +} + +const CHARS_PER_TOKEN = 4; // rough estimate; avoids pulling in a tokenizer dependency +const HEAD_KEEP = 6; // messages after system kept verbatim before dropping the middle + +function blockLength(content) { + if (typeof content === "string") return content.length; + if (Array.isArray(content)) { + return content.reduce((sum, b) => sum + (typeof b?.text === "string" ? b.text.length : 50), 0); + } + return 0; +} + +// Trim history to fit a (possibly smaller) context window by dropping the MIDDLE. +// Preserves: all system/instruction messages (head), and the trailing user run +// carrying the media the switch happened for (tail). Older middle turns between +// the head instructions and the current turn are dropped first. +export function stripHistoryForContext(body, contextWindow) { + const key = Array.isArray(body.messages) ? "messages" + : Array.isArray(body.input) ? "input" + : Array.isArray(body.contents) ? "contents" + : null; + if (!key) return body; + const arr = body[key]; + if (!arr || arr.length === 0) return body; + + const isSystem = (r) => r === "system" || r === "developer"; + const systemMsgs = arr.filter((m) => isSystem(m?.role)); + const rest = arr.filter((m) => !isSystem(m?.role)); + if (rest.length === 0) return body; + + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = rest.length - 1; + while (i >= 0 && !isAssistant(rest[i]?.role)) i--; + const tail = rest.slice(i + 1); // current user turn (has media) — always kept + const older = rest.slice(0, i + 1); // everything before it + if (older.length === 0) return body; + + const contentOf = (m) => m.content ?? m.parts; + // Cap at 80% of the adapter model's context window — leaves room for the response. + const budgetChars = (contextWindow || 200000) * 0.8 * CHARS_PER_TOKEN; + + // Prefer keeping the first HEAD_KEEP messages (initial instructions/context) verbatim; + // only trim further if even that exceeds the adapter model's context window. + const headKept = older.slice(0, HEAD_KEEP); + let total = systemMsgs.concat(headKept, tail).reduce((s, m) => s + blockLength(contentOf(m)), 0); + + // If head + tail overflow, drop head turns from the end (closest to middle) first. + let head = headKept; + while (total > budgetChars && head.length > 0) { + const dropped = head.pop(); + total -= blockLength(contentOf(dropped)); + } + + if (head.length === older.length) return body; + return { ...body, [key]: [...systemMsgs, ...head, ...tail] }; +} + +// Wrap a handleSingleModel callback so calls to a capacity-adapter model strip +// history to fit its context window first. No-op passthrough when the pool is empty. +export function withCapacityAdapterStripping(handleSingleModel, adapterModels) { + const adapterSet = new Set(adapterModels); + if (adapterSet.size === 0) return handleSingleModel; + return (body, modelStr, ...rest) => { + if (adapterSet.has(modelStr)) { + const slash = modelStr.indexOf("/"); + const provider = slash > 0 ? modelStr.slice(0, slash) : ""; + const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr; + const { contextWindow } = getCapabilitiesForModel(provider, model); + body = stripHistoryForContext(body, contextWindow); + } + return handleSingleModel(body, modelStr, ...rest); + }; +} diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2f..e3b9a8e6 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -106,23 +106,74 @@ export function detectRequiredCapabilities(body) { const required = new Set(); if (!body || typeof body !== "object") return required; + const addByMime = (mime) => { + if (typeof mime !== "string") return; + if (mime.startsWith("image/")) required.add("vision"); + else if (mime === "application/pdf") required.add("pdf"); + else if (mime.startsWith("audio/")) required.add("audioInput"); + else if (mime.startsWith("video/")) required.add("videoInput"); + }; + const scanBlock = (b) => { if (!b || typeof b !== "object") return; const t = b.type; if (t === "image_url" || t === "image" || t === "input_image") required.add("vision"); - if (t === "file" || t === "document" || t === "input_file") required.add("pdf"); + if (t === "input_audio" || t === "audio_url" || t === "audio") required.add("audioInput"); + if (t === "input_video" || t === "video_url" || t === "video") required.add("videoInput"); + if (t === "file" || t === "document" || t === "input_file") { + // Infer modality from embedded mime when available; fall back to pdf for generic files. + let fmime = null; + if (b.input_audio?.format) fmime = `audio/${b.input_audio.format}`; + else if (b.file?.file_data) fmime = String(b.file.file_data).match(/^data:([^;,]+)/)?.[1]; + else if (b.source?.media_type) fmime = b.source.media_type; + else if (b.source?.data) fmime = String(b.source.data).match(/^data:([^;,]+)/)?.[1]; + if (fmime) addByMime(fmime); + else required.add("pdf"); + } // gemini parts: inlineData/fileData carry a mime - const mime = b.inlineData?.mimeType || b.fileData?.mimeType; - if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision"); - if (mime === "application/pdf") required.add("pdf"); + addByMime(b.inlineData?.mimeType || b.fileData?.mimeType); }; const scanContent = (content) => { if (Array.isArray(content)) for (const b of content) scanBlock(b); }; + const scanMessage = (m) => { + if (!m || typeof m !== "object") return; + + // Ollama / Hermes images array (strings or objects) + if (Array.isArray(m.images) && m.images.length > 0) { + required.add("vision"); + } + + // Vercel AI SDK / Hermes attachments / experimental_attachments + const attachments = m.experimental_attachments || m.attachments; + if (Array.isArray(attachments)) { + for (const att of attachments) { + if (!att) continue; + const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]); + if (mime) addByMime(mime); + else if (att.url || att.data) required.add("vision"); + } + } + + // Direct message-level modality properties + if (m.image_url || m.image) required.add("vision"); + if (m.audio_url || m.audio) required.add("audioInput"); + + // Scan array content blocks + scanContent(m.content); + + // Scan string content for embedded data URIs + if (typeof m.content === "string") { + if (m.content.includes("data:image/")) required.add("vision"); + else if (m.content.includes("data:audio/")) required.add("audioInput"); + else if (m.content.includes("data:application/pdf")) required.add("pdf"); + } + }; + // Modalities: current user turn only (trailing user run across each known shape). - for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude + for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses const contents = body.contents || body.request?.contents; // gemini / antigravity for (const c of trailingUserItems(contents)) scanContent(c.parts); @@ -513,7 +564,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`); // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). - const { tools, tool_choice, ...rest } = body; + const { tools, tool_choice, stream_options, ...rest } = body; + // Fusion runs panel models non-streaming; drop stream_options too, or providers + // like DeepSeek reject it with "stream_options should be set along with stream = true". + // See issue #3024. const panelBody = { ...rest, stream: false }; // Flatten tool turns to prose so panel models keep context without emitting tool_calls. diff --git a/open-sse/services/cursorModels.js b/open-sse/services/cursorModels.js new file mode 100644 index 00000000..93e4330b --- /dev/null +++ b/open-sse/services/cursorModels.js @@ -0,0 +1,187 @@ +/** + * Cursor live model catalog fetcher. + * + * Cursor exposes the account-specific model picker through the AgentService + * `GetUsableModels` Connect RPC. Unlike the static provider registry, this + * includes models newly enabled for the account and omits unavailable ones. + */ + +import crypto from "crypto"; +import http2 from "http2"; +import { PROVIDER_OAUTH } from "../providers/index.js"; +import { buildCursorHeaders } from "../utils/cursorChecksum.js"; +import { decodeMessage } from "../utils/cursorProtobuf.js"; + +const FETCH_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 5 * 60 * 1000; + +// agent.v1.ModelDetails protobuf field numbers. +const MODEL_ID_FIELD = 1; +const DISPLAY_MODEL_ID_FIELD = 3; +const DISPLAY_NAME_FIELD = 4; +const DISPLAY_NAME_SHORT_FIELD = 5; +const RESPONSE_MODELS_FIELD = 1; + +/** @type {Map} */ +const catalogCache = new Map(); + +function getCursorModelsUrl() { + const config = PROVIDER_OAUTH.cursor; + if (!config?.agentEndpoint || !config?.modelsEndpoint) return null; + return `${config.agentEndpoint.replace(/\/$/, "")}${config.modelsEndpoint}`; +} + +function cacheKey(credentials) { + const seed = [ + credentials?.providerSpecificData?.machineId, + credentials?.accessToken, + ].filter(Boolean).join(":"); + if (!seed) return "cursor-anonymous"; + return crypto.createHash("sha256").update(`cursor:${seed}`).digest("hex"); +} + +function firstString(fields, fieldNumber) { + const value = fields.get(fieldNumber)?.[0]?.value; + if (!value || typeof value === "number") return ""; + return Buffer.from(value).toString("utf8"); +} + +/** + * Decode Cursor's `agent.v1.GetUsableModelsResponse` protobuf payload. + * The response contains repeated `agent.v1.ModelDetails` messages in field 1. + */ +export function parseCursorUsableModels(payload) { + const response = decodeMessage(payload); + const seen = new Set(); + const models = []; + + for (const entry of response.get(RESPONSE_MODELS_FIELD) || []) { + if (!entry?.value || typeof entry.value === "number") continue; + const detail = decodeMessage(entry.value); + const id = firstString(detail, MODEL_ID_FIELD).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const name = ( + firstString(detail, DISPLAY_NAME_FIELD) + || firstString(detail, DISPLAY_NAME_SHORT_FIELD) + || firstString(detail, DISPLAY_MODEL_ID_FIELD) + || id + ).trim(); + models.push({ id, name }); + } + + return models; +} + +/** + * agent.api5.cursor.sh is HTTP/2-only; Node fetch/undici cannot speak h2. + * Unary GetUsableModels uses an unframed protobuf body (application/proto). + */ +function http2PostProto(url, headers, body, signal, timeoutMs) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunks = []; + let responseHeaders = {}; + let settled = false; + + const finish = (fn) => (...args) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + try { client.close(); } catch {} + fn(...args); + }; + + const timeoutId = setTimeout(finish(() => { + reject(new Error("Cursor GetUsableModels timed out")); + }), timeoutMs); + + client.on("error", finish(reject)); + + const req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("response", (hdrs) => { responseHeaders = hdrs; }); + req.on("data", (chunk) => { chunks.push(chunk); }); + req.on("end", finish(() => { + resolve({ + status: Number(responseHeaders[":status"] || 0), + body: Buffer.concat(chunks), + }); + })); + req.on("error", finish(reject)); + + if (signal) { + const onAbort = finish(() => reject(new Error("Request aborted"))); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + req.end(body && body.length ? Buffer.from(body) : undefined); + }); +} + +async function fetchCursorCatalog(credentials, signal) { + const accessToken = credentials?.accessToken; + const machineId = credentials?.providerSpecificData?.machineId; + const url = getCursorModelsUrl(); + if (!accessToken || !machineId || !url) return null; + + const headers = { + ...buildCursorHeaders(accessToken, machineId, credentials?.providerSpecificData?.ghostMode !== false), + // Connect unary calls use an unframed protobuf body, unlike Cursor chat's + // streaming `application/connect+proto` endpoint. + accept: "application/proto", + "content-type": "application/proto", + }; + delete headers["connect-accept-encoding"]; + delete headers["connect-protocol-version"]; + + const response = await http2PostProto(url, headers, new Uint8Array(), signal, FETCH_TIMEOUT_MS); + if (response.status !== 200) { + const error = new Error(`Cursor GetUsableModels returned ${response.status}`); + error.status = response.status; + throw error; + } + + return parseCursorUsableModels(new Uint8Array(response.body)); +} + +/** + * Resolve the live Cursor catalog for the authenticated account. + * Returns null on any failure so callers can fall back to static models. + */ +export async function resolveCursorModels(credentials, options = {}) { + if (!credentials?.accessToken || !credentials?.providerSpecificData?.machineId) { + options.log?.debug?.("CURSOR_MODELS", "No Cursor access token or machine ID; skipping live fetch"); + return null; + } + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached?.expiresAt > now) return { models: cached.models }; + } + + try { + const models = await fetchCursorCatalog(credentials, options.signal); + if (!models?.length) return null; + catalogCache.set(key, { expiresAt: now + CACHE_TTL_MS, models }); + return { models }; + } catch (error) { + options.log?.warn?.("CURSOR_MODELS", `Live model fetch failed: ${error?.message || error}`); + return null; + } +} + +export function clearCursorModelCache() { + catalogCache.clear(); +} diff --git a/open-sse/services/grokCliModels.js b/open-sse/services/grokCliModels.js new file mode 100644 index 00000000..58f5c216 --- /dev/null +++ b/open-sse/services/grokCliModels.js @@ -0,0 +1,127 @@ +import { + GROK_CLI_BASE_URL, + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_MODEL, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../config/grokCli.js"; +import { refreshProviderCredentials } from "./oauthCredentialManager.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +const MODELS_URL = `${GROK_CLI_BASE_URL}/models`; + +function modelEntries(data) { + const value = Array.isArray(data) ? data : data?.data ?? data?.models ?? data?.results ?? []; + if (Array.isArray(value)) return value.map((item) => [null, item]); + if (value && typeof value === "object") return Object.entries(value); + return []; +} + +export function parseGrokCliModels(data) { + const seen = new Set(); + const models = []; + + for (const [key, raw] of modelEntries(data)) { + const item = typeof raw === "string" ? { id: raw } : raw; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const id = String( + item.id ?? item.model_id ?? item.modelId ?? item.model ?? item.slug ?? key ?? item.name ?? "", + ).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const model = { + ...item, + id, + name: item.display_name ?? item.displayName ?? item.name ?? id, + }; + const contextLength = Number( + item.context_length ?? item.contextLength ?? item.context_window ?? item.contextWindow, + ); + const maxOutputTokens = Number(item.max_output_tokens ?? item.maxOutputTokens); + if (Number.isFinite(contextLength) && contextLength > 0) model.contextLength = contextLength; + if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) { + model.maxOutputTokens = maxOutputTokens; + } + if (id === GROK_CLI_MODEL) { + model.contextLength ||= 500000; + model.maxOutputTokens ||= 64000; + } + models.push(model); + } + + return models; +} + +function buildHeaders(accessToken, providerSpecificData = {}) { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": GROK_CLI_USER_AGENT, + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": GROK_CLI_VERSION, + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-mode": "headless", + }; + const email = providerSpecificData?.email; + const userId = providerSpecificData?.userId || providerSpecificData?.principalId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + return headers; +} + +export async function resolveGrokCliModels(credentials, options = {}) { + const { + fetchFn = proxyAwareFetch, + log = console, + proxyOptions = null, + onCredentialsRefreshed, + } = options; + let accessToken = credentials?.accessToken; + if (!accessToken) return { models: [], warning: "Grok CLI access token is missing." }; + + const request = (token) => fetchFn( + MODELS_URL, + { + method: "GET", + headers: buildHeaders(token, credentials?.providerSpecificData), + }, + proxyOptions, + ); + + try { + let response = await request(accessToken); + if ((response.status === 401 || response.status === 403) && credentials?.refreshToken) { + const refreshed = await refreshProviderCredentials( + "grok-cli", + credentials, + log, + proxyOptions, + ); + if (refreshed?.accessToken) { + accessToken = refreshed.accessToken; + try { + await onCredentialsRefreshed?.(refreshed); + } catch (error) { + log?.warn?.("Grok CLI credential persistence failed", error); + } + response = await request(accessToken); + } + } + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + return { + models: [], + warning: `Grok CLI model discovery failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`, + }; + } + + const models = parseGrokCliModels(await response.json()); + return models.length + ? { models } + : { models: [], warning: "Grok CLI returned no selectable models." }; + } catch (error) { + return { models: [], warning: `Grok CLI model discovery failed: ${error.message}` }; + } +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 6558d707..5b88809c 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -17,6 +17,10 @@ for (const entry of REGISTRY) { for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id; } +const BUILTIN_MODEL_ALIASES = { + "grok-build": "gcli/grok-build", +}; + /** * Resolve provider alias to provider ID */ @@ -104,7 +108,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { : aliasesOrGetter; // Resolve alias - const resolved = resolveModelAliasFromMap(parsed.model, aliases); + const resolved = + resolveModelAliasFromMap(parsed.model, aliases) || + resolveModelAliasFromMap(parsed.model, BUILTIN_MODEL_ALIASES); if (resolved) { return resolved; } diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index f9a24e1a..84ab5a2b 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -7,7 +7,7 @@ * This significantly reduces the risk of being flagged by Google's anti-abuse systems. */ -import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js"; +import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js"; // ─── Cache ──────────────────────────────────────────────────────────────────── // connectionId -> { projectId: string, fetchedAt: number } @@ -83,7 +83,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,10 +155,12 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const headers = provider === "antigravity" ? ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS : LOAD_CODE_ASSIST_HEADERS; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", - headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + headers: { ...headers, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), signal }); @@ -185,7 +187,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints, provider); } /** @@ -196,10 +198,11 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints, provider) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; + const headers = provider === "antigravity" ? ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS : LOAD_CODE_ASSIST_HEADERS; const MAX_ATTEMPTS = 5; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { @@ -213,9 +216,9 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", - headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + headers: { ...headers, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), signal: localCtrl.signal }); diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1b02cd83..50f0589d 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -19,9 +19,15 @@ function isAnthropicCompatible(provider) { return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); } -function getOpenAICompatibleType(provider) { - if (!isOpenAICompatible(provider)) return "chat"; - return provider.includes("responses") ? "responses" : "chat"; +// Resolve the API type (chat vs responses) for an openai-compatible node. +// The stored apiType on the connection's providerSpecificData (kept in sync with +// the node on create/update) is authoritative. Falls back to the node ID +// substring for legacy nodes created before apiType was persisted — their IDs +// embed the type: openai-compatible--. +export function resolveOpenAICompatibleApiType(provider, credentials = null) { + const stored = credentials?.providerSpecificData?.apiType; + if (stored === "chat" || stored === "responses") return stored; + return typeof provider === "string" && provider.includes("responses") ? "responses" : "chat"; } // Detect request format from body structure @@ -105,9 +111,9 @@ export function detectFormat(body) { } // Get provider config (internal — no external runtime consumer) -function getProviderConfig(provider) { +function getProviderConfig(provider, credentials = null) { if (isOpenAICompatible(provider)) { - const apiType = getOpenAICompatibleType(provider); + const apiType = resolveOpenAICompatibleApiType(provider, credentials); return { ...PROVIDERS.openai, format: apiType === "responses" ? "openai-responses" : "openai", @@ -125,14 +131,14 @@ function getProviderConfig(provider) { } // Get target format for provider -export function getTargetFormat(provider) { +export function getTargetFormat(provider, credentials = null) { if (isOpenAICompatible(provider)) { - return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; + return resolveOpenAICompatibleApiType(provider, credentials) === "responses" ? "openai-responses" : "openai"; } if (isAnthropicCompatible(provider)) { return "claude"; } - const config = getProviderConfig(provider); + const config = getProviderConfig(provider, credentials); return config.format || "openai"; } diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index 01e6fb13..572931e5 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -10,6 +10,12 @@ * * On any error the live cache stays empty and chatExecuteCall surfaces the * problem to the user as "model config not yet fetched, retry shortly". + * + * PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY + * requests directly, so we exchange it for a short-lived job token (jt-...) + * via openapi.qoder.sh/api/v1/jobToken/exchange (plain JSON POST), then use + * that job token for signing. Job-token traffic must hit api2.qoder.sh — + * api3 rejects jt- with "Login expired" (403). */ import { createHash } from "crypto"; @@ -18,11 +24,30 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { buildCosyHeaders } from "../shared/qoder/cosy.js"; import { QODER_MODEL_LIST_URL, + QODER_CHAT_BASE_ALT, + QODER_JOB_TOKEN_EXCHANGE_URL, + QODER_USERINFO_URL, + QODER_IDE_VERSION, + QODER_CLIENT_TYPE, } from "../shared/qoder/constants.js"; const FETCH_TIMEOUT_MS = 15_000; const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog +const PAT_PREFIX = "pt-"; + +// PAT → job-token cache: a job token is short-lived (24h), so we keep it per +// PAT and re-exchange once it is within 5 minutes of expiry. +const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const PAT_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +export function isQoderPat(token) { + return typeof token === "string" && token.startsWith(PAT_PREFIX); +} + +/** @type {Map} */ +const patJobCache = new Map(); + /** @type {Map, fetched: boolean }>} */ const catalogCache = new Map(); @@ -34,6 +59,109 @@ const catalogCache = new Map(); */ const inflight = new Map(); +/** + * Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...). + * This endpoint is plain JSON POST — NOT COSY-signed. + */ +async function exchangeJobToken(pat, proxyOptions = null, signal = null) { + const res = await proxyAwareFetch( + QODER_JOB_TOKEN_EXCHANGE_URL, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + "Cosy-Version": QODER_IDE_VERSION, + "Cosy-ClientType": QODER_CLIENT_TYPE, + }, + body: JSON.stringify({ personal_token: pat }), + signal, + }, + proxyOptions, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`); + } + const data = await res.json(); + if (!data.token) throw new Error("qoder PAT exchange returned no job token"); + + let expiresAt = Date.now() + PAT_DEFAULT_TTL_MS; + if (data.expires_at) { + const parsed = Date.parse(data.expires_at); + if (!Number.isNaN(parsed)) expiresAt = parsed; + } else if (typeof data.expires_in === "number" && data.expires_in > 0) { + expiresAt = Date.now() + data.expires_in; + } + return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt }; +} + +/** + * Resolve the Qoder userId for a job token (needed for COSY signing). + * Returns "" on any failure — callers fall back to the stored userId. + */ +async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) { + try { + const res = await proxyAwareFetch( + QODER_USERINFO_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${jobToken}`, + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + }, + signal, + }, + proxyOptions, + ); + if (!res.ok) return ""; + const data = await res.json().catch(() => ({})); + return data.id || data.userId || data.user_id || ""; + } catch { + return ""; + } +} + +/** + * Resolve a PAT to a job-token credential, cached per-PAT. + */ +async function resolvePatCredential(pat, proxyOptions = null, signal = null) { + const cached = patJobCache.get(pat); + if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached; + + const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal); + const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal); + const resolved = { accessToken: jobToken, userId, expiresAt }; + patJobCache.set(pat, resolved); + return resolved; +} + +/** + * Resolve connection credentials to COSY-signable form: + * - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId + * - everything else → passed through unchanged + */ +export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) { + const raw = credentials?.apiKey || credentials?.accessToken; + if (isQoderPat(raw)) { + const resolved = await resolvePatCredential(raw, proxyOptions, signal); + return { + ...credentials, + accessToken: resolved.accessToken, + apiKey: undefined, + providerSpecificData: { + authMethod: "pat", + ...(credentials?.providerSpecificData || {}), + userId: resolved.userId || credentials?.providerSpecificData?.userId || "", + machineId: credentials?.providerSpecificData?.machineId || "", + }, + }; + } + return credentials; +} + /** * Stable cache key per credential (so different login sessions for the same * account share an entry). @@ -68,10 +196,16 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { const creds = cosyCredsFromConnection(credentials); if (!creds.userId || !creds.authToken) return null; + // Job-token traffic is rejected by api3 ("Login expired" 403) — the + // official qodercli serves it from api2 instead. + const modelListUrl = String(creds.authToken).startsWith("jt-") + ? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list` + : QODER_MODEL_LIST_URL; + const headers = { Accept: "application/json", "Accept-Encoding": "identity", - ...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds), + ...buildCosyHeaders(Buffer.alloc(0), modelListUrl, creds), }; const controller = new AbortController(); @@ -92,7 +226,7 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { } } response = await proxyAwareFetch( - QODER_MODEL_LIST_URL, + modelListUrl, { method: "GET", headers, @@ -159,11 +293,16 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) { * one upstream request per credential. */ export async function resolveQoderModels(credentials, options = {}) { - if (!credentials?.accessToken) return null; - const psd = credentials.providerSpecificData || {}; - if (!psd.userId) return null; + let resolved; + try { + resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal); + } catch (error) { + options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`); + return null; + } + if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null; - const key = cacheKey(credentials); + const key = cacheKey(resolved); const now = Date.now(); if (!options.forceRefresh) { const cached = catalogCache.get(key); @@ -180,7 +319,7 @@ export async function resolveQoderModels(credentials, options = {}) { } const fetchPromise = (async () => { - const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); + const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions); if (!fetched) return null; const entry = { expiresAt: Date.now() + CACHE_TTL_MS, diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index f759493e..3160f4a7 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -3,30 +3,38 @@ import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js"; import { refreshXaiToken, refreshAccessToken, + refreshKimiToken, refreshClaudeOAuthToken, refreshGoogleToken, - refreshQwenToken, refreshCodexToken, refreshKiroToken, refreshIflowToken, refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, } from "./tokenRefresh/providers.js"; // Re-export all provider refresh functions (preserves public API for all consumers) export { refreshAccessToken, + refreshKimiToken, refreshClaudeOAuthToken, refreshGoogleToken, - refreshQwenToken, refreshCodexToken, refreshKiroToken, refreshIflowToken, refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, }; @@ -44,7 +52,10 @@ export function isUnrecoverableRefreshError(result) { } export function getRefreshLeadMs(provider) { - return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS; + if (REFRESH_LEAD_MS[provider]) return REFRESH_LEAD_MS[provider]; + // Legacy id after kimi-coding → kimi merge + if (provider === "kimi-coding" && REFRESH_LEAD_MS.kimi) return REFRESH_LEAD_MS.kimi; + return TOKEN_EXPIRY_BUFFER_MS; } export function parseVertexSaJson(apiKey) { @@ -124,12 +135,21 @@ const REFRESH_HANDLERS = { antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log), claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log), codex: (c, log) => refreshCodexToken(c.refreshToken, log), - qwen: (c, log) => refreshQwenToken(c.refreshToken, log), iflow: (c, log) => refreshIflowToken(c.refreshToken, log), github: (c, log) => refreshGitHubToken(c.refreshToken, log), kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log), xai: (c, log) => refreshXaiToken(c.refreshToken, log), + // Grok CLI shares xAI OAuth client + token endpoint (device-code tokens refresh the same way) + "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), + gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), + "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), + trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + zed: () => refreshZedToken(), + windsurf: (c, log) => refreshWindsurfToken(c, log), + // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here + kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log), + "kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log), vertex: vertexRefreshHandler, "vertex-partner": vertexRefreshHandler }; @@ -182,11 +202,11 @@ export function formatProviderCredentials(provider, credentials, log) { }; case "codex": - case "qwen": case "iflow": case "openai": case "openrouter": case "xai": + case "grok-cli": return { apiKey: credentials.apiKey, accessToken: credentials.accessToken diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 54c2fa0b..40f27f51 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -1,5 +1,5 @@ import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js"; -import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js"; +import { OAUTH_ENDPOINTS, GITHUB_COPILOT, buildKimiHeaders } from "../../config/appConstants.js"; import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { dedupRefresh } from "./dedup.js"; import { buildExternalIdpRefreshParams } from "../../../src/lib/oauth/kiroExternalIdp.js"; @@ -31,10 +31,63 @@ export async function refreshXaiToken(refreshToken, log) { }, log); } +// Per-provider refresh variants for the generic path. Keys not listed fall back +// to the default form-encoded OAuth2 refresh with client_id + client_secret. +const REFRESH_PROFILES = { + claude: { + bodyFormat: "json", + includeClientSecret: false, + url: () => OAUTH_ENDPOINTS.anthropic.token, + dedupKey: "claude", + }, + iflow: { + url: () => OAUTH_ENDPOINTS.iflow.token, + dedupKey: "iflow", + extraHeaders: (creds, cfg) => ({ + Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`, + }), + }, + github: { + url: () => OAUTH_ENDPOINTS.github.token, + dedupKey: "github", + includeClientSecret: (cfg) => !!cfg?.clientSecret, + }, + kimi: { + dedupKey: "kimi", + extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId), + }, +}; + +function resolveRefreshUrl(provider, config, profile) { + if (profile?.url) { + try { return profile.url(); } catch { /* fall through */ } + } + return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null; +} + +function buildRefreshBody(profile, config, refreshToken) { + const fmt = profile?.bodyFormat === "json" ? "json" : "form"; + const includeSecret = profile?.includeClientSecret === undefined + ? true + : typeof profile.includeClientSecret === "function" + ? profile.includeClientSecret(config) + : profile.includeClientSecret; + const payload = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }; + if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret; + if (fmt === "json") return { format: "json", body: JSON.stringify(payload) }; + return { format: "form", body: new URLSearchParams(payload) }; +} + export async function refreshAccessToken(provider, refreshToken, credentials, log) { const config = PROVIDERS[provider]; + const profile = REFRESH_PROFILES[provider] || {}; + const url = resolveRefreshUrl(provider, config, profile); - if (!config || !config.refreshUrl) { + if (!config || !url) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); return null; } @@ -44,21 +97,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - return dedupRefresh(provider, refreshToken, async () => { + const dedupKey = profile.dedupKey || provider; + + return dedupRefresh(dedupKey, refreshToken, async () => { try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); + const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken); + const headers = { + "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", + Accept: "application/json", + ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}), + }; + const response = await fetch(url, { method: "POST", headers, body }); if (!response.ok) { const errorText = await response.text(); @@ -81,6 +130,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(profile.parse ? (profile.parse(tokens) || {}) : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { @@ -91,37 +141,15 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo }, log); } +// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers +// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers. +export async function refreshKimiToken(refreshToken, credentials, log) { + return refreshAccessToken("kimi", refreshToken, credentials, log); +} + +// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); + return refreshAccessToken("claude", refreshToken, {}, log); } export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { @@ -158,60 +186,6 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l }, log); } -export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); -} - export function classifyOAuthRefreshError(errorText = "", status = 0) { let parsed = null; try { @@ -434,95 +408,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, }, log); } +// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...). export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("iflow", refreshToken, {}, log); } +// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...). export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("github", refreshToken, {}, log); } export async function refreshCopilotToken(githubAccessToken, log) { @@ -622,3 +515,146 @@ export async function refreshCodebuddyToken(refreshToken, log) { }; }, log); } + +export async function refreshCodebuddyIntlToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codebuddy-intl", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} + +// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}. +// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}. +export async function refreshTraeToken(refreshToken, credentials, log) { + if (!refreshToken) return null; + const oauth = PROVIDER_OAUTH.trae || {}; + const url = oauth.exchangeTokenUrl || oauth.tokenUrl; + if (!url) { + log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured"); + return null; + } + + return dedupRefresh("trae", refreshToken, async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + }, + body: JSON.stringify({ + ClientID: oauth.clientId || "ono9krqynydwx5", + RefreshToken: refreshToken, + ClientSecret: oauth.clientSecret || "-", + UserID: "", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", { + status: response.status, + error: errorText, + }); + return null; + } + + const payload = await response.json(); + const result = payload?.Result || payload?.result || payload; + const accessToken = result?.AccessToken || result?.accessToken; + if (!accessToken) { + log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload }); + return null; + } + + const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken; + const expiresAt = result?.ExpiresAt || result?.expiresAt; + let expiresIn; + if (typeof expiresAt === "number") { + expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); + } else if (typeof expiresAt === "string") { + const ms = new Date(expiresAt).getTime() - Date.now(); + expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", { + hasNewAccessToken: !!accessToken, + hasNewRefreshToken: newRefresh !== refreshToken, + expiresIn, + }); + + return { + accessToken, + refreshToken: newRefresh, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`); + return null; + } + }, log); +} + +// Zed access_token is long-lived; auth flow returns no refresh_token. +// No refresh possible — re-login required when token expires/revoked. +// Mirrors cursor/kilocode null-refresh pattern. +export function refreshZedToken() { + return null; +} + +// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token +// grant yields a fresh apiKey). Refresh handled out-of-band by the caller. +// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed, +// re-run RegisterUser with the refreshed Firebase JWT (separate code path). +export async function refreshWindsurfToken(credentials, log) { + log?.info?.( + "TOKEN_REFRESH", + "windsurf: apiKey is long-lived (no refresh_token flow) — skipping" + ); + return null; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 4c56dc1b..65062467 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -10,9 +10,12 @@ import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitReset export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; -import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; +import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js"; +import { getGrokCliUsage } from "./usage/grok-cli.js"; +import { getKimiUsage } from "./usage/kimi.js"; +import { getDeepseekUsage } from "./usage/deepseek.js"; +import { resolveQoderCredentials } from "./qoderModels.js"; import { - getQwenUsage, getIflowUsage, getOllamaUsage, getGlmUsage, @@ -30,22 +33,30 @@ const USAGE_HANDLERS = { github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), "gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions), antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), - claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions), + claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), - qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions), - qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData), + qoder: async (c) => { + // PAT (pt-...) connections must be exchanged to a job token before the + // quota endpoint accepts them. + const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null); + return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions); + }, iflow: (c) => getIflowUsage(c.accessToken), - ollama: (c) => getOllamaUsage(c.accessToken), + ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions), glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions), "glm-cn": (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions), minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), + "codebuddy-intl": (c) => getCodeBuddyIntlUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), + "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), + deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), }; -export async function getUsageForProvider(connection, proxyOptions = null) { +export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; const providerDataWithProjectId = { ...(providerSpecificData || {}), @@ -54,5 +65,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) { const handler = USAGE_HANDLERS[provider]; if (!handler) return { message: `Usage API not implemented for ${provider}` }; - return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions }); + return await handler({ + provider, + accessToken, + apiKey, + providerSpecificData, + providerDataWithProjectId, + proxyOptions, + force: options.force === true, + }); } diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index 85ab8e6f..ce3e01a0 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.js @@ -19,7 +19,43 @@ const CLAUDE_CONFIG = { const OAUTH_429_COOLDOWN_MS = 180000; const oauthCooldown = new Map(); -export async function getClaudeUsage(accessToken, proxyOptions = null) { +// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh +// all funnel through here; without this each call hits Anthropic and triggers 429. +const USAGE_CACHE_TTL_MS = 300000; +const usageCache = new Map(); // token -> { promise } | { result, expiresAt } + +export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) { + const force = options?.force === true; + + // Serve in-flight or fresh cached result (skip on manual force) + if (!force && accessToken) { + const hit = usageCache.get(accessToken); + if (hit?.promise) return hit.promise; + if (hit && hit.expiresAt > Date.now()) return hit.result; + } + + const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null; + + const promise = (async () => { + const result = await fetchClaudeUsageRaw(accessToken, proxyOptions); + // Only cache real quota data, not soft-failure {message: ...} payloads + if (accessToken && result?.quotas) { + usageCache.set(accessToken, { + result, + expiresAt: Date.now() + USAGE_CACHE_TTL_MS, + }); + return result; + } + // Soft failure (429/error): prefer the last good read over a transient error + if (stale) return stale; + return result; + })(); + + if (accessToken) usageCache.set(accessToken, { promise }); + return promise; +} + +async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { try { // Skip OAuth usage call while this token is cooling down from a recent 429 const cooldownUntil = oauthCooldown.get(accessToken); diff --git a/open-sse/services/usage/codebuddy-cn.js b/open-sse/services/usage/codebuddy-cn.js index d355c729..e61d56c6 100644 --- a/open-sse/services/usage/codebuddy-cn.js +++ b/open-sse/services/usage/codebuddy-cn.js @@ -43,17 +43,17 @@ function refillCadence(acc) { return "Monthly"; } -export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) { +async function getCodeBuddyUsage(providerId, accessToken, apiKey, providerSpecificData, proxyOptions = null) { const token = accessToken || apiKey; if (!token) { - return { message: "CodeBuddy CN credential not available." }; + return { message: `CodeBuddy (${providerId}) credential not available.` }; } try { - const response = await proxyAwareFetch(U(PROVIDER_ID).url, { + const response = await proxyAwareFetch(U(providerId).url, { method: "POST", headers: { - ...(PROVIDERS[PROVIDER_ID]?.headers || {}), + ...(PROVIDERS[providerId]?.headers || {}), Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", @@ -129,10 +129,18 @@ export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificD }); const basePkg = refills[0] || accounts[0] || {}; - const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN"; + const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy"; return { plan, quotas }; } catch (error) { - return { message: `CodeBuddy CN error: ${error.message}` }; + return { message: `CodeBuddy (${providerId}) error: ${error.message}` }; } } + +export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) { + return getCodeBuddyUsage(PROVIDER_ID, accessToken, apiKey, providerSpecificData, proxyOptions); +} + +export async function getCodeBuddyIntlUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) { + return getCodeBuddyUsage("codebuddy-intl", accessToken, apiKey, providerSpecificData, proxyOptions); +} diff --git a/open-sse/services/usage/deepseek.js b/open-sse/services/usage/deepseek.js new file mode 100644 index 00000000..cb70a40d --- /dev/null +++ b/open-sse/services/usage/deepseek.js @@ -0,0 +1,112 @@ +/** + * DeepSeek usage — GET https://api.deepseek.com/user/balance + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { toFiniteNumber } from "./shared.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function parseBalanceInfos(data) { + const list = Array.isArray(data?.balance_infos) ? data.balance_infos : []; + const results = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const currency = + typeof item.currency === "string" ? item.currency.toUpperCase() : ""; + if (!currency) continue; + const totalBalance = toFiniteNumber( + item.total_balance ?? item.totalBalance, + 0, + ); + results.push({ + currency, + totalBalance, + grantedBalance: toFiniteNumber( + item.granted_balance ?? item.grantedBalance, + 0, + ), + toppedUpBalance: toFiniteNumber( + item.topped_up_balance ?? item.toppedUpBalance, + 0, + ), + }); + } + return results; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getDeepseekUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "DeepSeek API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401 || response.status === 403) { + return { + plan: "DeepSeek", + message: "DeepSeek authentication failed. Check the API key.", + }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + return { + plan: "DeepSeek", + message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== "object") { + return { message: "DeepSeek balance response was not JSON." }; + } + + const balances = parseBalanceInfos(data); + if (balances.length === 0) { + return { + plan: "DeepSeek", + message: "DeepSeek connected. No balance data returned.", + }; + } + + const isAvailable = data.is_available === true || data.isAvailable === true; + const quotas = {}; + for (const b of balances) { + const total = Math.max(0, b.totalBalance); + // Credit pot: show full remaining against current balance; never set absolute + // `remaining` — QuotaTable treats it as a 0–100 percentage. + quotas[`Balance (${b.currency})`] = { + used: 0, + total, + remainingPercentage: total > 0 ? 100 : 0, + resetAt: null, + unlimited: total > 0, + }; + } + + return { + plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)", + quotas, + }; + } catch (error) { + return { message: `DeepSeek error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index 71c53e89..c7564c00 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -2,15 +2,15 @@ * Google usage handlers (Gemini CLI + Antigravity) */ -import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js"; -import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js"; +import { CLIENT_METADATA } from "../../config/appConstants.js"; +import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION, ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js"; import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js"; // Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here const ANTIGRAVITY_CONFIG = { ...U("antigravity"), ...ANTIGRAVITY_OAUTH_CLIENT, - userAgent: getPlatformUserAgent(), + userAgent: ANTIGRAVITY_IDE_USER_AGENT, }; /** @@ -129,8 +129,7 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro "User-Agent": ANTIGRAVITY_CONFIG.userAgent, "Content-Type": "application/json", "X-Client-Name": "antigravity", - "X-Client-Version": "1.107.0", - "x-request-source": "local", // MITM bypass + "X-Client-Version": ANTIGRAVITY_IDE_VERSION, }, body: JSON.stringify({ ...(projectId ? { project: projectId } : {}) @@ -162,7 +161,12 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro if (data.models) { // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) const importantModels = [ - 'gemini-3-flash-agent', + 'gemini-3.7-flash-high', + 'gemini-3.7-flash-medium', + 'gemini-3.7-flash-low', + 'gemini-3.6-flash-high', + 'gemini-3.6-flash-medium', + 'gemini-3.6-flash-low', 'gemini-3.5-flash-low', 'gemini-3.5-flash-extra-low', 'gemini-pro-agent', @@ -170,10 +174,8 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro 'claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium', - 'gemini-3-flash', // Image generation models 'gemini-3.1-flash-image', - 'gemini-3-pro-image', ]; for (const [modelKey, info] of Object.entries(data.models)) { @@ -229,7 +231,6 @@ async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) "Authorization": `Bearer ${accessToken}`, "User-Agent": ANTIGRAVITY_CONFIG.userAgent, "Content-Type": "application/json", - "x-request-source": "local", // MITM bypass }, body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), }, 10000, proxyOptions); diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js new file mode 100644 index 00000000..67518e1c --- /dev/null +++ b/open-sse/services/usage/grok-cli.js @@ -0,0 +1,424 @@ +/** + * Grok CLI / Grok Build usage handler + * + * Source of truth: official grok-shell/grok-pager traffic to cli-chat-proxy.grok.com + * GET /v1/billing?format=credits + * GET /v1/user?include=subscription + * + * Observed billing shape (protobuf-json style `{ val: number }`): + * { + * config: { + * currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end }, + * onDemandCap: { val }, + * onDemandUsed: { val }, + * prepaidBalance: { val }, + * isUnifiedBillingUser: true, + * billingPeriodStart, billingPeriodEnd + * } + * } + * + * Exhausted free/promo accounts return cap=0/used=0/prepaid=0 and chat 402s with + * personal-team-blocked:spending-limit. Paid/sub accounts surface non-zero cap + * or prepaidBalance; richer credit fields are parsed opportunistically if present. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; +import { + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../../config/grokCli.js"; +import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js"; + +const USAGE = U("grok-cli"); +const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; + +// SuperGrok weekly pool. +const GRPC_CREDITS_URL = + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; +// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns +// grpc-status 13 "Missing request message." with a 0-byte body. +const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]); + +/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ +function unwrapVal(value, fallback = 0) { + if (value == null) return fallback; + if (typeof value === "object" && !Array.isArray(value) && "val" in value) { + return toFiniteNumber(value.val, fallback); + } + return toFiniteNumber(value, fallback); +} + +function buildGrokCliHeaders(accessToken, providerSpecificData = {}) { + const psd = providerSpecificData || {}; + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": GROK_CLI_USER_AGENT, + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-version": GROK_CLI_VERSION, + "x-grok-client-mode": "headless", + }; + const email = psd.email; + const userId = psd.userId || psd.principalId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + return headers; +} + +function subscriptionTier(user, config) { + const rawTier = + user?.subscriptionTier ?? + user?.subscription_tier ?? + user?.subscription?.tier ?? + config?.subscriptionTier ?? + config?.subscription_tier; + return typeof rawTier === "string" ? rawTier.trim() : ""; +} + +function resolvePlan(user, config) { + const tier = subscriptionTier(user, config); + if (tier) { + return tier + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + if (user?.hasGrokCodeAccess === true) return "Grok Code"; + if (config?.isUnifiedBillingUser === true) return "Grok Build"; + return "Grok Build"; +} + +// Display only; upstream remains authoritative for access and quota enforcement. +function planFromAccessToken(accessToken) { + try { + const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url")); + return { + 0: "Free", + 1: "SuperGrok", + 2: "X Basic", + 3: "X Premium", + 4: "X Premium Plus", + 5: "SuperGrok Heavy", + 6: "SuperGrok Lite", + }[payload.tier] || ""; + } catch { + return ""; + } +} + +function makeQuota({ used, total, resetAt, unlimited = false }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Do NOT set absolute `remaining` — QuotaTable's getRemainingPercentage treats + // `remaining` as a 0–100 percentage (same trap as Qoder credits). + if (unlimited || safeTotal === 0) { + return { + used: safeUsed, + total: 0, + remainingPercentage: unlimited ? 100 : 0, + resetAt: resetAt || null, + unlimited: true, + }; + } + const remaining = Math.max(0, safeTotal - safeUsed); + const remainingPercentage = (remaining / safeTotal) * 100; + return { + used: safeUsed, + total: safeTotal, + remainingPercentage, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * Map billing JSON → normalized quotas object for the dashboard. + * Returns { quotas, periodEnd, exhaustedHint } or empty quotas when nothing usable. + */ +export function parseGrokCliBilling(billing, user = null) { + const root = billing && typeof billing === "object" ? billing : {}; + const config = + root.config && typeof root.config === "object" && !Array.isArray(root.config) + ? root.config + : root; + + const periodEnd = + parseResetTime(config.billingPeriodEnd) || + parseResetTime(config.billing_period_end) || + parseResetTime(config.currentPeriod?.end) || + parseResetTime(config.resetAt || config.resetsAt || config.periodEnd) || + parseResetTime(root.billingPeriodEnd) || + parseResetTime(root.billing_period_end) || + parseResetTime(root.resetAt || root.resetsAt || root.periodEnd) || + null; + + const quotas = {}; + const tier = subscriptionTier(user, config); + const subscriptionAccess = Boolean(tier) && !/^(free|none|null)$/i.test(tier); + + // Current Grok Build responses expose included monthly usage at top level. + const monthlyLimit = unwrapVal( + config.monthlyLimit ?? config.monthly_limit ?? root.monthlyLimit ?? root.monthly_limit, + NaN, + ); + const includedUsed = unwrapVal( + config.includedUsed ?? config.included_used ?? root.includedUsed ?? root.included_used, + NaN, + ); + const totalUsed = unwrapVal( + config.totalUsed ?? config.total_used ?? root.totalUsed ?? root.total_used, + NaN, + ); + if (Number.isFinite(monthlyLimit) && monthlyLimit > 0) { + quotas["Monthly included"] = makeQuota({ + used: Number.isFinite(includedUsed) + ? includedUsed + : Number.isFinite(totalUsed) + ? totalUsed + : 0, + total: monthlyLimit, + resetAt: periodEnd, + }); + } + + // Primary: on-demand spending window (subscription / promo credits) + const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN); + const onDemandUsed = unwrapVal(config.onDemandUsed ?? root.onDemandUsed, NaN); + if (Number.isFinite(onDemandCap) && onDemandCap > 0) { + const used = Number.isFinite(onDemandUsed) ? Math.max(0, onDemandUsed) : 0; + quotas["On-demand"] = makeQuota({ + used, + total: onDemandCap, + resetAt: periodEnd, + }); + } else if ( + !subscriptionAccess && + Number.isFinite(onDemandCap) && + onDemandCap === 0 && + Number.isFinite(onDemandUsed) + ) { + // Cap 0 is the exhausted free/promo state (chat returns 402 spending-limit). + // UI treats total===0 as unlimited, so use a synthetic 1/1 depleted row. + quotas["On-demand"] = { + used: 1, + total: 1, + remainingPercentage: 0, + resetAt: periodEnd, + unlimited: false, + }; + } + + // Prepaid top-up balance (remaining credits; no fixed allotment known) + const prepaid = unwrapVal(config.prepaidBalance ?? root.prepaidBalance, NaN); + if (Number.isFinite(prepaid) && prepaid > 0) { + // Show full bar against the current balance (0 spent of this remaining pot). + quotas["Prepaid"] = { + used: 0, + total: prepaid, + remainingPercentage: 100, + resetAt: null, + unlimited: false, + }; + } + + // SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is + // the single total used %; productUsage is a breakdown legend, NOT independent + // quotas — never split it into separate bars. + const usedPct = unwrapVal( + config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent, + NaN, + ); + if (Number.isFinite(usedPct) && usedPct >= 0) { + quotas["Weekly SuperGrok"] = makeQuota({ + used: Math.max(0, Math.min(100, usedPct)), + total: 100, + resetAt: periodEnd, + }); + } + + // Opportunistic richer credit envelopes (future / other account types) + const creditBags = [ + root.credits, + root.creditBalance, + root.usage, + config.credits, + config.includedCredits, + config.subscriptionCredits, + ].filter((bag) => bag && typeof bag === "object" && !Array.isArray(bag)); + + for (const bag of creditBags) { + const total = unwrapVal( + bag.total ?? bag.limit ?? bag.cap ?? bag.allocation ?? bag.amount, + NaN, + ); + const used = unwrapVal(bag.used ?? bag.spent ?? bag.consumed, NaN); + const remaining = unwrapVal(bag.remaining ?? bag.balance ?? bag.left, NaN); + if (Number.isFinite(total) && total > 0) { + const resolvedUsed = Number.isFinite(used) + ? used + : Number.isFinite(remaining) + ? Math.max(0, total - remaining) + : 0; + if (!quotas.Credits) { + quotas.Credits = makeQuota({ + used: resolvedUsed, + total, + resetAt: parseResetTime(bag.resetAt || bag.resetsAt || bag.end) || periodEnd, + }); + } + } else if (Number.isFinite(remaining) && remaining >= 0 && !quotas.Credits) { + quotas.Credits = { + used: 0, + total: remaining > 0 ? remaining : 1, + remainingPercentage: remaining > 0 ? 100 : 0, + resetAt: periodEnd, + unlimited: false, + }; + } + } + + // Exhausted when every finite quota bar is at 0% remaining + const exhausted = + Object.keys(quotas).length > 0 && + Object.values(quotas).every( + (q) => q.unlimited !== true && (q.remainingPercentage ?? 100) <= 0, + ); + + return { + plan: resolvePlan(user, config), + quotas, + periodEnd, + exhausted, + subscriptionAccess, + rawConfig: config, + }; +} + +/** + * Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig. + * Fail-open: any network/auth/parse failure returns null. + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) { + if (!accessToken) return null; + try { + const res = await proxyAwareFetch( + GRPC_CREDITS_URL, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + Accept: "application/grpc-web+proto", + }, + body: GRPC_WEB_EMPTY_REQUEST_FRAME, + }, + proxyOptions, + ); + if (!res?.ok) return null; + const arrayBuffer = await res.arrayBuffer().catch(() => null); + if (!arrayBuffer) return null; + return decodeGrokCreditsFrame(Buffer.from(arrayBuffer)); + } catch { + return null; + } +} + +function quotasFromGrpcCredits(decoded) { + if (!decoded || !Number.isFinite(decoded.percentUsed)) return null; + // Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35) + const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed))); + return { + "Weekly SuperGrok": makeQuota({ + used, + total: 100, + resetAt: decoded.resetAt || null, + }), + }; +} + +/** + * @param {string} accessToken + * @param {object|null} providerSpecificData + * @param {object|null} proxyOptions + */ +export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) { + if (!accessToken) { + return { message: "Grok CLI access token not available." }; + } + + const headers = buildGrokCliHeaders(accessToken, providerSpecificData); + + try { + // Fetch billing + user profile in parallel (same pattern as official CLI startup) + const [billingRes, userRes] = await Promise.all([ + proxyAwareFetch( + BILLING_URL, + { method: "GET", headers }, + proxyOptions, + ), + proxyAwareFetch( + USER_URL, + { method: "GET", headers }, + proxyOptions, + ).catch(() => null), + ]); + + if (billingRes.status === 401 || billingRes.status === 403) { + return { message: "Grok CLI authentication expired. Please re-authorize." }; + } + + if (!billingRes.ok) { + const errText = await billingRes.text().catch(() => ""); + const trimmed = errText ? `: ${errText.slice(0, 200)}` : ""; + return { message: `Grok CLI billing API error (${billingRes.status})${trimmed}` }; + } + + const billing = await billingRes.json().catch(() => null); + if (!billing || typeof billing !== "object") { + return { message: "Grok CLI billing response was not JSON." }; + } + + let user = null; + if (userRes?.ok) { + user = await userRes.json().catch(() => null); + } + + const parsed = parseGrokCliBilling(billing, user); + parsed.plan = planFromAccessToken(accessToken) || parsed.plan; + + if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) { + // Paid SuperGrok often returns cap=0 over REST but exposes the shared + // weekly pool on GetGrokCreditsConfig — try that before giving up. + const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions); + const grpcQuotas = quotasFromGrpcCredits(grpc); + if (grpcQuotas) { + return { + plan: parsed.plan, + quotas: grpcQuotas, + }; + } + return { + plan: parsed.plan, + message: parsed.subscriptionAccess + ? "Subscription access is active; Grok does not expose a numeric included quota." + : "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted.", + quotas: {}, + }; + } + + // Dashboard hides QuotaTable whenever `message` is set, so only attach a + // message when there are no quota rows to render. Depleted accounts keep + // the 0% On-demand bar without a blocking message. + return { + plan: parsed.plan, + quotas: parsed.quotas, + }; + } catch (error) { + return { message: `Grok CLI usage error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/grokCliQuotaFrame.js b/open-sse/services/usage/grokCliQuotaFrame.js new file mode 100644 index 00000000..1578bec6 --- /dev/null +++ b/open-sse/services/usage/grokCliQuotaFrame.js @@ -0,0 +1,191 @@ +/** + * gRPC-web frame decoder for xAI GetGrokCreditsConfig + * (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig). + * + * Real response shape (live capture 2026-07-20): + * top-level field 1 (length-delimited) — nested credits info + * subfield 1 (fixed32 float) — usage ratio 0..1 + * subfield 5 (Timestamp{seconds,nanos}) — credit-pool reset time + * + * Fail-open: any malformed buffer returns null, never throws. + */ + +const FIELD_CREDITS_INFO = 1; +const CREDITS_FIELD_USAGE_RATIO = 1; +const CREDITS_FIELD_RESET_TIMESTAMP = 5; +const TIMESTAMP_FIELD_SECONDS = 1; +const TIMESTAMP_FIELD_NANOS = 2; + +const WIRE_TYPE_VARINT = 0; +const WIRE_TYPE_FIXED64 = 1; +const WIRE_TYPE_LENGTH_DELIMITED = 2; +const WIRE_TYPE_FIXED32 = 5; + +const GRPC_WEB_TRAILER_FLAG_BIT = 0x80; +const MAX_VARINT_SHIFT_BITS = 70n; + +/** + * Validate a gRPC-web frame header at `offset`. + * @returns {{ flag: number, payloadStart: number, payloadLength: number } | null} + */ +export function probeFrameHeader(buffer, offset = 0) { + if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null; + const flag = buffer[offset]; + if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null; + const payloadStart = offset + 5; + const payloadLength = buffer.readUInt32BE(offset + 1); + if (payloadLength > buffer.length - payloadStart) return null; + return { flag, payloadStart, payloadLength }; +} + +function readVarint(buffer, offset) { + let result = 0n; + let shift = 0n; + let pos = offset; + for (;;) { + if (pos >= buffer.length) return null; + const byte = buffer[pos]; + result |= BigInt(byte & 0x7f) << shift; + pos += 1; + if ((byte & 0x80) === 0) break; + shift += 7n; + if (shift > MAX_VARINT_SHIFT_BITS) return null; + } + return { value: Number(result), next: pos }; +} + +function readLengthDelimitedField(buffer, offset) { + const lengthResult = readVarint(buffer, offset); + if (!lengthResult) return null; + const { value: length, next: bodyStart } = lengthResult; + if (length < 0 || bodyStart + length > buffer.length) return null; + return { + field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) }, + next: bodyStart + length, + }; +} + +function readFixedWidthField(buffer, offset, width, wireType) { + if (offset + width > buffer.length) return null; + return { + field: { wireType, bytes: buffer.subarray(offset, offset + width) }, + next: offset + width, + }; +} + +function readField(buffer, offset) { + const tagResult = readVarint(buffer, offset); + if (!tagResult) return null; + const fieldNumber = tagResult.value >>> 3; + const wireType = tagResult.value & 0x7; + if (fieldNumber === 0) return null; + + if (wireType === WIRE_TYPE_VARINT) { + const valueResult = readVarint(buffer, tagResult.next); + if (!valueResult) return null; + return { + fieldNumber, + field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value }, + next: valueResult.next, + }; + } + if (wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const result = readLengthDelimitedField(buffer, tagResult.next); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED64) { + const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED32) { + const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + return null; +} + +function decodeFields(buffer) { + const fields = new Map(); + let offset = 0; + while (offset < buffer.length) { + const result = readField(buffer, offset); + if (!result) return null; + fields.set(result.fieldNumber, result.field); + offset = result.next; + } + return fields; +} + +function findDataFramePayload(buffer) { + let offset = 0; + while (offset < buffer.length) { + const frame = probeFrameHeader(buffer, offset); + if (!frame) return null; + const frameEnd = frame.payloadStart + frame.payloadLength; + const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0; + if (!isTrailer) { + return buffer.subarray(frame.payloadStart, frameEnd); + } + offset = frameEnd; + } + return null; +} + +function extractNestedMessage(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + return decodeFields(field.bytes); +} + +function extractUsageRatio(field) { + if (!field) return 0; // proto3 omission = 0% used + if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0); + if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0); + return null; +} + +function extractResetAt(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + + const timestampFields = decodeFields(field.bytes); + if (!timestampFields) return null; + + const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS); + const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS); + const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0; + const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0; + + const millis = seconds * 1000 + Math.round(nanos / 1_000_000); + const parsed = new Date(millis); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +/** + * Decode GetGrokCreditsConfig response → `{ percentUsed: 0-100, resetAt }` or null. + * @param {Buffer} buffer + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export function decodeGrokCreditsFrame(buffer) { + if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null; + + try { + const framed = probeFrameHeader(buffer, 0) !== null; + const payload = framed ? findDataFramePayload(buffer) : buffer; + if (!payload) return null; + + const topLevelFields = decodeFields(payload); + if (!topLevelFields) return null; + + const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO)); + if (!creditsInfo) return null; + + const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO)); + if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null; + + return { + percentUsed: Math.min(100, usageRatio * 100), + resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)), + }; + } catch { + return null; + } +} diff --git a/open-sse/services/usage/kimi.js b/open-sse/services/usage/kimi.js new file mode 100644 index 00000000..4400965b --- /dev/null +++ b/open-sse/services/usage/kimi.js @@ -0,0 +1,211 @@ +/** + * Kimi Coding usage — GET /v1/usages + * + * Dual auth (single provider id `kimi`): + * - apiKey present → x-api-key only (platform / coding API key) + * - else accessToken → Bearer + X-Msh-* (device-code OAuth) + * + * Note: chat messages use combined x-api-key; /usages OAuth is Bearer. + * 403 permission_denied is NOT auth-expired — account lacks usage feature / sub. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; +import { buildKimiHeaders } from "../../config/appConstants.js"; + +const USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +const PLAN_LEVELS = { + LEVEL_BASIC: "Moderato", + LEVEL_INTERMEDIATE: "Allegretto", + LEVEL_ADVANCED: "Allegro", + LEVEL_STANDARD: "Vivace", +}; + +function getKimiPlanName(level) { + if (!level) return ""; + const key = String(level); + if (PLAN_LEVELS[key]) return PLAN_LEVELS[key]; + return key.replace(/^LEVEL_/, "").toLowerCase(); +} + +/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */ +export function formatKimiUsageError(status, responseText) { + let parsed = null; + try { + parsed = JSON.parse(responseText || ""); + } catch { + /* plain text */ + } + + const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null; + const debug = detail0?.debug || parsed?.debug || null; + const reason = debug?.reason || parsed?.reason || ""; + const localized = + debug?.localizedMessage?.message || + detail0?.localizedMessage?.message || + parsed?.message || + ""; + + if (status === 401) { + return "Kimi authentication expired. Please re-authorize."; + } + + // Live OAuth token without Kimi Code usage entitlement returns 403 + // REASON_FEATURE_NO_PERMISSION — not an expired session. + if ( + status === 403 && + (reason === "REASON_FEATURE_NO_PERMISSION" || + /permission_denied|do not have permission|subscribe/i.test( + `${parsed?.code || ""} ${localized} ${responseText || ""}`, + )) + ) { + return ( + localized || + "Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota." + ); + } + + const snippet = (localized || responseText || "").slice(0, 100); + return snippet + ? `Kimi Coding connected. API Error ${status}: ${snippet}` + : `Kimi Coding connected. API Error ${status}`; +} + +function makeQuota({ used, total, remaining, resetAt }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Prefer provider remaining when present; never set absolute `remaining` + // on the quota object — QuotaTable treats it as a 0–100 percentage. + let remainingPct; + if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) { + remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100; + } else if (safeTotal > 0) { + remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100; + } else { + remainingPct = 0; + } + return { + used: safeUsed, + total: safeTotal, + remainingPercentage: remainingPct, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + * @param {object|null} providerSpecificData + */ +export async function getKimiUsage( + accessToken = null, + apiKey = null, + proxyOptions = null, + providerSpecificData = null, +) { + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0; + + if (!useApiKey && !useOAuth) { + return { message: "Kimi access token or API key not available." }; + } + + const authHeaders = useApiKey + ? { "x-api-key": apiKey } + : { + Authorization: `Bearer ${accessToken}`, + ...buildKimiHeaders(providerSpecificData?.deviceId), + }; + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + const responseText = await response.text().catch(() => ""); + + if (!response.ok) { + return { + plan: "Kimi Coding", + message: formatKimiUsageError(response.status, responseText), + }; + } + + let data; + try { + data = JSON.parse(responseText || "{}"); + } catch { + return { + plan: "Kimi Coding", + message: "Kimi Coding connected. Invalid JSON response from API.", + }; + } + + const quotas = {}; + const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {}; + const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0); + const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0); + const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining; + const usageRemaining = + usageRemainingRaw != null && usageRemainingRaw !== "" + ? toFiniteNumber(usageRemainingRaw, NaN) + : NaN; + const usageResetTime = + usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; + + if (usageLimit > 0) { + quotas.Weekly = makeQuota({ + used: usageUsed, + total: usageLimit, + remaining: Number.isFinite(usageRemaining) ? usageRemaining : null, + resetAt: parseResetTime(usageResetTime), + }); + } + + const limitsArray = Array.isArray(data?.limits) ? data.limits : []; + for (const item of limitsArray) { + if (!item || typeof item !== "object") continue; + const detail = item.detail && typeof item.detail === "object" ? item.detail : {}; + const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0); + const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN); + const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; + if (limit > 0) { + const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit); + quotas.Ratelimit = makeQuota({ + used: Math.max(0, limit - rem), + total: limit, + remaining: rem, + resetAt: parseResetTime(resetTime), + }); + } + } + + const membershipLevel = data?.user?.membership?.level; + const planName = getKimiPlanName(membershipLevel) || "Kimi Coding"; + + if (Object.keys(quotas).length > 0) { + return { plan: planName, quotas }; + } + + return { + plan: planName, + message: "Kimi Coding connected. Usage tracked per request.", + }; + } catch (error) { + return { + message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`, + }; + } +} diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index 6ce012fa..fc133eff 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -1,5 +1,5 @@ /** - * Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder) + * Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder) */ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; @@ -15,23 +15,6 @@ const GLM_QUOTA_URLS = { // Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url; -/** - * Qwen Usage - */ -export async function getQwenUsage(accessToken, providerSpecificData) { - try { - const resourceUrl = providerSpecificData?.resourceUrl; - if (!resourceUrl) { - return { message: "Qwen connected. No resource URL available." }; - } - - // Qwen may have usage endpoint at resource URL - return { message: "Qwen connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch Qwen usage." }; - } -} - /** * iFlow Usage */ @@ -46,23 +29,86 @@ export async function getIflowUsage(accessToken) { /** * Ollama Cloud Usage - * Ollama Cloud uses an API key from ollama.com/settings/keys - * and has no public usage API — free tier has light usage limits (resets every 5h & 7d). - * This returns an informational message with the plan details. + * GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1 + * ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed. + * POST https://ollama.com/api/me — plan label (fail-open). + * Auth: Authorization: Bearer */ -export async function getOllamaUsage(accessToken, providerSpecificData) { +export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) { + if (!apiKey) { + return { message: "Ollama Cloud API key not available." }; + } + try { - // Ollama Cloud does not expose a public quota/usage API. - // The provider is configured as noAuth with a notice explaining limits. - // We return a graceful message so the UI shows a friendly state instead of an error. - const plan = providerSpecificData?.plan || "Free"; - return { - plan, - message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.", - quotas: [], - }; + const response = await proxyAwareFetch("https://ollama.com/api/usage", { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "Ollama Cloud API key invalid or expired." }; + } + + if (!response.ok) { + return { message: `Ollama Cloud usage API error (${response.status}).` }; + } + + let data; + try { + data = await response.json(); + } catch { + return { message: "Ollama Cloud usage response was not JSON." }; + } + + // Best-effort plan label from /api/me + const me = await proxyAwareFetch("https://ollama.com/api/me", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Content-Length": "0", + }, + }, proxyOptions).then((r) => (r.ok ? r.json() : null)).catch(() => null); + + const planRaw = typeof me?.Plan === "string" ? me.Plan : ""; + const plan = planRaw + ? planRaw.charAt(0).toUpperCase() + planRaw.slice(1).toLowerCase() + : "Ollama Cloud"; + + const limits = data?.limits && typeof data.limits === "object" ? data.limits : {}; + + // Ollama `usage` is a 0..1 ratio (1.0 = limit reached). Convert to a 0..100 + // bar. Do NOT set absolute `remaining` — QuotaTable reads remainingPercentage. + function ratioQuota(usageRatio, resetAt = null) { + const ratio = Math.max(0, Math.min(1, Number(usageRatio) || 0)); + const usedPct = Math.round(ratio * 100); + return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false }; + } + + const sessionRaw = limits.session?.usage; + const weeklyRaw = limits.weekly?.usage; + const sessionNum = Number(sessionRaw); + const weeklyNum = Number(weeklyRaw); + const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum); + const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum); + + if (!hasSession && !hasWeekly) { + return { + plan, + message: "Ollama Cloud connected. No usage limits reported.", + quotas: {}, + }; + } + + const quotas = {}; + if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum); + if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum); + + return { plan, quotas }; } catch (error) { - return { message: "Unable to fetch Ollama Cloud usage." }; + return { message: `Ollama Cloud error: ${error.message}` }; } } diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 1d9ce303..e2635f40 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -11,6 +11,9 @@ export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh"; export const QODER_CENTER_BASE = "https://center.qoder.sh"; export const QODER_CHAT_BASE = "https://api3.qoder.sh"; +// Job-token (jt-...) traffic is rejected by api3 with "Login expired" (403); +// the official qodercli serves it from api2 instead. +export const QODER_CHAT_BASE_ALT = "https://api2.qoder.sh"; export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts"; @@ -20,6 +23,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; +// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange. +// PATs cannot sign COSY requests directly — they must be exchanged first. +// This endpoint is NOT COSY-signed (plain JSON POST). +export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`; + // Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js new file mode 100644 index 00000000..aa3337d7 --- /dev/null +++ b/open-sse/shared/zedAuth.js @@ -0,0 +1,415 @@ +// Zed hosted LLM aggregator — auth + model-catalog helpers. +// +// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA +// keypair instead of a registered OAuth client_id/secret: +// 1. Client generates an ephemeral RSA keypair. +// 2. Sends the public key to zed.dev/native_app_signin. +// 3. User signs in via browser; Zed redirects to a local callback with the +// access token RSA-encrypted against the public key. +// 4. Client decrypts locally with the private key that never left the host. +// No embedded client_id/secret — the credential is a per-login keypair. + +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +export const ZED_WEB_BASE_URL = "https://zed.dev"; +export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev"; +export const ZED_LLM_BASE_URL = "https://cloud.zed.dev"; + +export const ZED_HEADERS = { + expiredToken: "x-zed-expired-token", + outdatedToken: "x-zed-outdated-token", + clientSupportsStatus: "x-zed-client-supports-status-messages", + clientSupportsStreamEnded: + "x-zed-client-supports-stream-ended-request-completion-status", + serverSupportsStatus: "x-zed-server-supports-status-messages", + clientSupportsXai: "x-zed-client-supports-x-ai", + systemId: "x-zed-system-id", +}; + +const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; +const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; + +const llmTokenCache = new Map(); +const modelCache = new Map(); +const modelInflight = new Map(); + +function b64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function b64urlPadded(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromB64url(value) { + return Buffer.from(String(value || ""), "base64url").toString("utf8"); +} + +function normalizeBaseUrl(baseUrl, fallback) { + return String(baseUrl || fallback).replace(/\/+$/, ""); +} + +function zedUrl(config, key, path, fallbackBase) { + const base = normalizeBaseUrl(config?.[key], fallbackBase); + return `${base}${path}`; +} + +/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */ +export function encodeZedPrivateKeyVerifier(privateKeyPem) { + return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`; +} + +export function decodeZedPrivateKeyVerifier(verifier) { + const value = String(verifier || ""); + if (!value.startsWith(PRIVATE_KEY_PREFIX)) { + throw new Error("Missing Zed private key verifier; restart the login flow"); + } + return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length)); +} + +/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */ +export function createZedNativeAuthData(config = {}, options = {}) { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + + const nativeAppPort = Number( + options.nativeAppPort || config.defaultNativeAppPort || 58443, + ); + const systemId = options.systemId || crypto.randomUUID(); + const publicKeyString = b64urlPadded(publicKey); + const signInUrl = new URL( + `${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`, + ); + signInUrl.searchParams.set("native_app_port", String(nativeAppPort)); + signInUrl.searchParams.set("native_app_public_key", publicKeyString); + if (systemId) signInUrl.searchParams.set("system_id", systemId); + + return { + authUrl: signInUrl.toString(), + privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey), + nativeAppPort, + systemId, + publicKey: publicKeyString, + }; +} + +/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */ +export function parseZedCallbackPayload(input) { + const raw = String(input || "").trim(); + if (!raw) throw new Error("Missing Zed callback URL"); + + let data = {}; + try { + data = JSON.parse(raw); + } catch { + let url; + try { + url = new URL(raw); + } catch { + try { + url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + } catch { + throw new Error("Invalid Zed callback URL"); + } + } + url.searchParams.forEach((value, key) => { + data[key] = value; + }); + } + + const userId = data.user_id || data.userId; + const encryptedAccessToken = data.access_token || data.accessToken || data.token; + if (!userId || !encryptedAccessToken) { + throw new Error("Zed callback must include user_id and access_token"); + } + return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) }; +} + +/** Decrypt the RSA-encrypted access token using the stored private key. */ +export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { + const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); + const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + encrypted, + ) + .toString("utf8"); + } catch (oaepError) { + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + encrypted, + ) + .toString("utf8"); + } catch { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + } + } +} + +export function buildZedUserAuthHeader(credentials) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId; + const accessToken = credentials?.accessToken || credentials?.apiKey; + if (!userId || !accessToken) { + throw new Error("Zed credential is missing userId or accessToken"); + } + return `${userId} ${accessToken}`; +} + +function getSystemId(credentials) { + return String( + credentials?.providerSpecificData?.systemId || credentials?.systemId || "", + ); +} + +async function fetchJson(url, options) { + const res = await proxyAwareFetch(url, options); + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + if (!res.ok) { + const message = + data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`; + const err = new Error(String(message)); + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +export async function fetchZedAuthenticatedUser(credentials, options = {}) { + const config = options.config || {}; + const headers = { + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { + method: "GET", + headers, + signal: options.signal ?? undefined, + }); +} + +function normalizeOrganizationId(value) { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null) { + if (typeof value[0] === "string") return value[0]; + if (typeof value.id === "string") return value.id; + } + return String(value); +} + +export function resolveZedOrganizationId(credentials, userInfo = null) { + const psd = credentials?.providerSpecificData || {}; + const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId); + if (explicit) return explicit; + const fromUser = normalizeOrganizationId( + userInfo?.default_organization_id || userInfo?.defaultOrganizationId, + ); + if (fromUser) return fromUser; + const orgs = userInfo?.organizations || []; + const org = orgs.find((item) => item?.is_personal) || orgs[0]; + return normalizeOrganizationId(org?.id); +} + +function zedUserCacheKey(credentials, organizationId) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId || "unknown"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${userId}:${organizationId || "default"}:${token.slice(-16)}`; +} + +function zedModelCacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const org = psd.organizationId || psd.defaultOrganizationId || "default"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`; +} + +export async function fetchZedLlmToken(credentials, options = {}) { + const config = options.config || {}; + let organizationId = options.organizationId || resolveZedOrganizationId(credentials); + if (!organizationId) { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + organizationId = resolveZedOrganizationId(credentials, userInfo); + } + if (!organizationId) throw new Error("No Zed organization selected"); + + const cacheKey = zedUserCacheKey(credentials, organizationId); + const cached = llmTokenCache.get(cacheKey); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token; + + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + const data = await fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL), + { + method: "POST", + headers, + body: JSON.stringify({ organization_id: organizationId }), + signal: options.signal ?? undefined, + }, + ); + const token = + typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; + if (!token) throw new Error("Zed did not return an LLM token"); + llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS }); + return token; +} + +export function shouldRefreshZedLlmToken(response) { + return ( + response?.status === 401 || + !!response?.headers?.has?.(ZED_HEADERS.expiredToken) || + !!response?.headers?.has?.(ZED_HEADERS.outdatedToken) + ); +} + +export async function zedLlmFetch(credentials, path, options = {}) { + const config = options.config || {}; + const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL); + const buildRequest = async (forceRefresh) => { + const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh }); + return proxyAwareFetch(url, { + ...options.fetchOptions, + headers: { + ...(options.fetchOptions?.headers || {}), + Authorization: `Bearer ${token}`, + }, + signal: options.signal ?? undefined, + }); + }; + + let response = await buildRequest(false); + if (shouldRefreshZedLlmToken(response)) { + response = await buildRequest(true); + } + return response; +} + +function normalizeZedModelId(id) { + if (!id) return ""; + if (typeof id === "string") return id; + if (typeof id === "object" && id !== null) { + if (typeof id[0] === "string") return id[0]; + if (typeof id.id === "string") return id.id; + } + return String(id); +} + +export function mapZedModel(model) { + const id = normalizeZedModelId(model?.id); + if (!id) return null; + return { + id, + name: model.display_name || model.displayName || id, + provider: model.provider, + isLatest: !!model.is_latest, + contextLength: model.max_token_count ?? model.maxTokenCount, + contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode, + maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens, + supportsTools: !!model.supports_tools, + supportsImages: !!model.supports_images, + supportsThinking: !!model.supports_thinking, + supportsDisablingThinking: !!model.supports_disabling_thinking, + supportsFastMode: !!model.supports_fast_mode, + supportsServerSideCompaction: !!model.supports_server_side_compaction, + supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [], + supportsStreamingTools: !!model.supports_streaming_tools, + supportsParallelToolCalls: !!model.supports_parallel_tool_calls, + isDisabled: !!model.is_disabled, + disabledReason: model.disabled_reason ?? null, + }; +} + +/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */ +export async function resolveZedModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const key = zedModelCacheKey(credentials); + const cached = modelCache.get(key); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached; + + const existing = modelInflight.get(key); + if (existing && !options.forceRefresh) return existing; + + const promise = (async () => { + const response = await zedLlmFetch(credentials, "/models", { + ...options, + fetchOptions: { + method: "GET", + headers: { + Accept: "application/json", + [ZED_HEADERS.clientSupportsXai]: "true", + }, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zed models failed: ${response.status} ${text}`); + } + const data = await response.json(); + const rawModels = Array.isArray(data?.models) ? data.models : []; + const models = rawModels + .map(mapZedModel) + .filter(Boolean) + .filter((model) => !model.isDisabled); + const rawById = new Map(); + for (const raw of rawModels) { + const id = normalizeZedModelId(raw?.id); + if (id) rawById.set(id, raw); + } + const entry = { + expiresAt: Date.now() + MODEL_CACHE_TTL_MS, + models, + rawModels, + rawById, + defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel), + defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel), + recommendedModels: (data?.recommended_models || data?.recommendedModels || []) + .map(normalizeZedModelId) + .filter(Boolean), + }; + modelCache.set(key, entry); + return entry; + })(); + + modelInflight.set(key, promise); + try { + return await promise; + } finally { + if (modelInflight.get(key) === promise) modelInflight.delete(key); + } +} + +export function clearZedCaches() { + llmTokenCache.clear(); + modelCache.clear(); + modelInflight.clear(); +} diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js new file mode 100644 index 00000000..11d49dc7 --- /dev/null +++ b/open-sse/translator/concerns/kiroConversation.js @@ -0,0 +1,435 @@ +import { + KIRO_TOOL_DESCRIPTION_MAX_LENGTH, + KIRO_TOOL_ID_MAX_LENGTH, + KIRO_TOOL_NAME_MAX_LENGTH, +} from "../../config/kiroConstants.js"; + +const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function text(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendText(target, extra) { + if (!extra) return; + target.content = target.content ? `${target.content}\n\n${extra}` : extra; +} + +function trimCodePoints(value, limit) { + return [...String(value || "")].slice(0, limit).join(""); +} + +function uniqueName(rawName, index, usedNames) { + const cleaned = String(rawName || "") + .trim() + .replace(TOOL_NAME_PATTERN, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH); + let candidate = base; + let suffix = 2; + while (usedNames.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`; + } + usedNames.add(candidate); + return candidate; +} + +function cleanSchemaValue(value) { + if (Array.isArray(value)) return value.map(cleanSchemaValue); + if (!value || typeof value !== "object") return value; + + const cleaned = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "additionalProperties") continue; + if (key === "required" && Array.isArray(child) && child.length === 0) continue; + cleaned[key] = cleanSchemaValue(child); + } + return cleaned; +} + +function normalizeRootSchema(schema) { + const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {}); + cleaned.type = "object"; + if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) { + cleaned.properties = {}; + } + if (Array.isArray(cleaned.required)) { + cleaned.required = [...new Set(cleaned.required.filter( + (name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name) + ))]; + if (cleaned.required.length === 0) delete cleaned.required; + } + return cleaned; +} + +/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */ +export function normalizeKiroToolSpecs(tools) { + const specs = []; + const nameMap = new Map(); + const usedNames = new Set(); + + for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) { + if (!tool || typeof tool !== "object") continue; + const rawName = tool.function?.name ?? tool.name; + if (typeof rawName !== "string" || !rawName.trim()) continue; + + // A repeated definition with the same source name describes the same tool. + if (nameMap.has(rawName)) continue; + const name = uniqueName(rawName, index, usedNames); + nameMap.set(rawName, name); + + const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`; + const description = trimCodePoints( + String(rawDescription || `Tool: ${rawName}`), + KIRO_TOOL_DESCRIPTION_MAX_LENGTH + ); + const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {}; + specs.push({ + toolSpecification: { + name, + description, + inputSchema: { json: normalizeRootSchema(schema) }, + }, + }); + } + + return { specs, nameMap }; +} + +function toolCallText(toolUse) { + return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`; +} + +function toolResultText(toolResult) { + const content = Array.isArray(toolResult?.content) + ? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n") + : text(toolResult?.content); + return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`; +} + +function mergeUser(target, source) { + appendText(target, source.content); + if (Array.isArray(source.images) && source.images.length > 0) { + target.images = [...(target.images || []), ...source.images]; + } + const results = source.userInputMessageContext?.toolResults; + if (Array.isArray(results) && results.length > 0) { + target.userInputMessageContext ||= {}; + target.userInputMessageContext.toolResults = [ + ...(target.userInputMessageContext.toolResults || []), + ...results, + ]; + } +} + +function mergeAssistant(target, source) { + appendText(target, source.content); + if (Array.isArray(source.toolUses) && source.toolUses.length > 0) { + target.toolUses = [...(target.toolUses || []), ...source.toolUses]; + } +} + +function normalizeTurns(history, currentMessage, modelId) { + const rawTurns = [...(Array.isArray(history) ? history : [])]; + if (currentMessage) rawTurns.push(currentMessage); + const turns = []; + + for (const raw of rawTurns) { + const isUser = !!raw?.userInputMessage; + const isAssistant = !!raw?.assistantResponseMessage; + if (isUser === isAssistant) continue; + + const turn = isUser + ? { userInputMessage: clone(raw.userInputMessage) } + : { assistantResponseMessage: clone(raw.assistantResponseMessage) }; + const previous = turns[turns.length - 1]; + if (turn.userInputMessage && previous?.userInputMessage) { + mergeUser(previous.userInputMessage, turn.userInputMessage); + } else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) { + mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage); + } else { + turns.push(turn); + } + } + + if (turns[0]?.assistantResponseMessage) { + turns.unshift({ userInputMessage: { content: "continue", modelId } }); + } + if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) { + turns.push({ userInputMessage: { content: "continue", modelId } }); + } + + for (const turn of turns) { + if (turn.userInputMessage) { + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.modelId ||= modelId; + if (turn.userInputMessage.userInputMessageContext?.tools) { + delete turn.userInputMessage.userInputMessageContext.tools; + } + } else { + turn.assistantResponseMessage.content = + text(turn.assistantResponseMessage.content).trim() || "..."; + } + } + return turns; +} + +function rawId(value) { + return typeof value === "string" ? value : ""; +} + +function reserveToolId(value, turnIndex, callIndex, name, usedIds) { + const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, ""); + const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`; + const base = trimCodePoints( + TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated, + KIRO_TOOL_ID_MAX_LENGTH + ); + let candidate = base; + let suffix = 2; + while (usedIds.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`; + } + usedIds.add(candidate); + return candidate; +} + +function normalizeToolInput(input) { + if (input && typeof input === "object" && !Array.isArray(input)) return clone(input); + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return null; + } + } + return input == null ? {} : null; +} + +function normalizeToolResult(result) { + const content = Array.isArray(result?.content) + ? result.content.map((part) => ({ text: text(part?.text ?? part) })) + : [{ text: text(result?.content) }]; + return { + toolUseId: rawId(result?.toolUseId), + status: result?.status === "error" ? "error" : "success", + content: content.length > 0 ? content : [{ text: "" }], + }; +} + +function flattenResults(userMessage, results) { + for (const result of results) appendText(userMessage, toolResultText(result)); +} + +function cleanUserContext(userMessage) { + const context = userMessage.userInputMessageContext; + if (!context) return; + if (!context.toolResults?.length) delete context.toolResults; + if (!context.tools?.length) delete context.tools; + if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext; +} + +function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) { + const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : []; + const results = Array.isArray(user.userInputMessageContext?.toolResults) + ? user.userInputMessageContext.toolResults.map(normalizeToolResult) + : []; + if (calls.length === 0) { + if (results.length > 0) { + flattenResults(user, results); + repairs.orphanResults += results.length; + } + if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + return; + } + + const callQueues = new Map(); + const callRecords = calls.map((call, callIndex) => { + const key = rawId(call?.toolUseId); + const mappedName = nameMap.get(call?.name) || call?.name; + const input = normalizeToolInput(call?.input); + const record = { call, callIndex, key, mappedName, input, result: null }; + const queue = callQueues.get(key) || []; + queue.push(record); + callQueues.set(key, queue); + return record; + }); + + const orphanResults = []; + for (const result of results) { + const queue = callQueues.get(rawId(result.toolUseId)); + const record = queue?.find((candidate) => !candidate.result); + if (record) record.result = result; + else orphanResults.push(result); + } + + const keptCalls = []; + const keptResults = []; + for (const record of callRecords) { + const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName); + const valid = !!record.result && hasSpec && record.input !== null; + if (!valid) { + appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input })); + repairs.missingResults += record.result ? 0 : 1; + repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1; + if (record.result) { + flattenResults(user, [record.result]); + repairs.orphanResults++; + } + continue; + } + + const toolUseId = reserveToolId( + record.key, + turnIndex, + record.callIndex, + record.mappedName, + usedIds + ); + keptCalls.push({ + toolUseId, + name: record.mappedName, + input: record.input, + }); + keptResults.push({ ...record.result, toolUseId }); + } + + if (orphanResults.length > 0) { + flattenResults(user, orphanResults); + repairs.orphanResults += orphanResults.length; + } + + if (keptCalls.length > 0) assistant.toolUses = keptCalls; + else delete assistant.toolUses; + user.userInputMessageContext ||= {}; + if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults; + else delete user.userInputMessageContext.toolResults; + cleanUserContext(user); +} + +/** Validate the final Kiro wire conversation without mutating it. */ +export function validateKiroConversation(history, currentMessage, toolSpecs = []) { + const errors = []; + const turns = [...(history || []), currentMessage].filter(Boolean); + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index++) { + const expectedUser = index % 2 === 0; + const isUser = !!turns[index]?.userInputMessage; + if (isUser !== expectedUser) errors.push(`role:${index}`); + if (!isUser) { + const calls = turns[index].assistantResponseMessage?.toolUses || []; + const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || []; + const callIds = calls.map((call) => call.toolUseId); + const resultIds = results.map((result) => result.toolUseId); + if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) { + errors.push(`pair:${index}`); + } + for (const call of calls) { + if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`); + usedIds.add(call.toolUseId); + if (!specNames.has(call.name)) errors.push(`spec:${index}`); + } + } else if (index === 0) { + const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults; + if (results?.length) errors.push("orphan:0"); + } + } + if (!currentMessage?.userInputMessage?.content) errors.push("current"); + return { valid: errors.length === 0, errors }; +} + +function flattenAllStructuredTools(turns, repairs) { + for (const turn of turns) { + if (turn.assistantResponseMessage?.toolUses?.length) { + for (const call of turn.assistantResponseMessage.toolUses) { + appendText(turn.assistantResponseMessage, toolCallText(call)); + } + repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length; + delete turn.assistantResponseMessage.toolUses; + } + const user = turn.userInputMessage; + const results = user?.userInputMessageContext?.toolResults; + if (results?.length) { + flattenResults(user, results); + repairs.orphanResults += results.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } +} + +/** + * Produce a strict Kiro conversation: alternating turns, current user message, + * adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage. + */ +export function canonicalizeKiroConversation({ + history, + currentMessage, + modelId, + toolSpecs = [], + nameMap = new Map(), +} = {}) { + const turns = normalizeTurns(history, currentMessage, modelId); + const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 }; + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index += 2) { + const user = turns[index].userInputMessage; + if (index === 0) { + const leadingResults = user.userInputMessageContext?.toolResults || []; + if (leadingResults.length > 0) { + flattenResults(user, leadingResults); + repairs.orphanResults += leadingResults.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } + const assistant = turns[index + 1]?.assistantResponseMessage; + const nextUser = turns[index + 2]?.userInputMessage; + if (assistant && nextUser) { + reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs); + } + } + + const finalCurrent = turns[turns.length - 1]; + finalCurrent.userInputMessage.userInputMessageContext ||= {}; + if (toolSpecs.length > 0) { + finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs); + } + cleanUserContext(finalCurrent.userInputMessage); + + let finalHistory = turns.slice(0, -1); + let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + if (!validation.valid) { + flattenAllStructuredTools(turns, repairs); + finalHistory = turns.slice(0, -1); + validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + } + + return { + history: finalHistory, + currentMessage: finalCurrent, + repairs, + valid: validation.valid, + errors: validation.errors, + }; +} diff --git a/open-sse/translator/concerns/modality.js b/open-sse/translator/concerns/modality.js index b3bba2de..d2d62f4e 100644 --- a/open-sse/translator/concerns/modality.js +++ b/open-sse/translator/concerns/modality.js @@ -62,6 +62,19 @@ function stripOpenAI(body, caps) { if (!Array.isArray(body.messages)) return; const last = body.messages.length - 1; body.messages.forEach((msg, i) => { + if (caps.vision === false) { + if (Array.isArray(msg.images)) delete msg.images; + if (Array.isArray(msg.experimental_attachments)) { + msg.experimental_attachments = msg.experimental_attachments.filter( + (a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/"))) + ); + } + if (Array.isArray(msg.attachments)) { + msg.attachments = msg.attachments.filter( + (a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/"))) + ); + } + } if (!Array.isArray(msg.content)) return; const removed = new Set(); msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last); diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index dc030194..e222b23f 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -1,17 +1,26 @@ +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; + // Strip request params a given provider/model rejects upstream (e.g. HTTP 400). // Config-driven: add a rule instead of scattering `delete body.x` across executors. // Each rule: optional provider, regex match on model, list of params to drop. // A param is removed only when it is present (!== undefined). const STRIP_RULES = [ - // claude-opus-4 series: temperature is deprecated (Anthropic 400). #1748 - { match: /claude-opus-4/i, drop: ["temperature"] }, + // All Claude models: temperature deprecated/rejected upstream (Anthropic 400). #1748 + { match: /claude/i, drop: ["temperature"] }, // GitHub Copilot gpt-5.4: temperature unsupported. { provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] }, // GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713 { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) { provider: "cloudflare-ai", flattenContent: true }, + { provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true }, + // VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's + // advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144), + // so clampToModelMaxOutput alone leaves it uncapped and the request 400s with + // "integer above maximum value, expected <= 32768". Pin an explicit endpoint cap; + // min() with the model ceiling still applies if a variant's own limit is lower. + { provider: "volcengine-ark", match: /kimi/i, maxOutputCap: 32768, clampToModelMaxOutput: true }, ]; // Test a rule's match (regex or predicate) against the model id. @@ -20,6 +29,12 @@ function matches(rule, model) { return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model); } +function clampNumber(body, key, ceiling) { + if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] > ceiling) { + body[key] = ceiling; + } +} + // Remove unsupported params from body in place; returns body. export function stripUnsupportedParams(provider, model, body) { if (!model || !body || typeof body !== "object") return body; @@ -39,6 +54,22 @@ export function stripUnsupportedParams(provider, model, body) { } } } + if (rule.clampToModelMaxOutput || Number.isFinite(rule.maxOutputCap)) { + const modelCeiling = getCapabilitiesForModel(provider, model).maxOutput; + const candidates = []; + if (rule.clampToModelMaxOutput && Number.isFinite(modelCeiling) && modelCeiling > 0) { + candidates.push(modelCeiling); + } + if (Number.isFinite(rule.maxOutputCap) && rule.maxOutputCap > 0) { + candidates.push(rule.maxOutputCap); + } + if (candidates.length > 0) { + const ceiling = Math.min(...candidates); + clampNumber(body, "max_tokens", ceiling); + clampNumber(body, "max_completion_tokens", ceiling); + clampNumber(body, "max_output_tokens", ceiling); + } + } } return body; } diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 1cf44384..d18f47b6 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -3,6 +3,7 @@ // never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A. import { getCapabilitiesForModel } from "../../providers/capabilities.js"; +import { getThinkingLevels } from "../../providers/thinkingLevels.js"; import { PROVIDERS } from "../../providers/index.js"; import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js"; @@ -20,6 +21,13 @@ const FORMAT_TO_NATIVE = { kiro: "kiro", }; +// Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent). +export function stripThinkingSuffix(model) { + if (typeof model !== "string") return model; + const m = model.match(/^(.*)\([^()]+\)\s*$/); + return m ? m[1].trim() : model; +} + // Parse model-name suffix "model(value)" → { cleanModel, override }. // value: level name (high) | number (8192) | auto | none. null override when absent. export function parseSuffix(model) { @@ -30,6 +38,7 @@ export function parseSuffix(model) { const raw = m[2].trim().toLowerCase(); if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } }; if (raw === "auto") return { cleanModel, override: { mode: "auto" } }; + if (raw === "ultra") return { cleanModel, override: { mode: "level", level: raw } }; if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } }; if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } }; return { cleanModel, override: null }; @@ -127,23 +136,78 @@ function toLevel(cfg) { return null; } +function normalizeOpenAILevel(level, supportedLevels) { + if (level !== "max" && level !== "ultra") return level; + if (supportedLevels?.includes(level)) return level; + if (level === "ultra" && supportedLevels?.includes("max")) return "max"; + return "xhigh"; +} + function toGeminiThinkingLevel(cfg) { const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high"); return effortToThinkingLevel(raw); } +function toKimiReasoningEffort(cfg) { + const level = toLevel(cfg); + if (level === "auto") return "high"; + if (level === "minimal") return "low"; + if (level === "xhigh") return "max"; + if (["low", "medium", "high", "max"].includes(level)) return level; + return null; +} + +const GEMINI_LEVEL_OUTPUT_FLOOR = { + minimal: 4096, + low: 8192, + medium: 16384, + high: 65535, +}; + +function geminiBudgetOutputFloor(budget) { + if (budget === -1) return 32768; + if (!Number.isFinite(budget)) return 32768; + if (budget <= 1024) return 8192; + if (budget <= 8192) return 16384; + if (budget <= 24576) return 32768; + return 65535; +} + +function geminiLevelOutputFloor(level) { + return GEMINI_LEVEL_OUTPUT_FLOOR[level] || GEMINI_LEVEL_OUTPUT_FLOOR.high; +} + // Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap // the whole request in a { request: { generationConfig } } envelope — target the // envelope's generationConfig when present, else the top-level one. +function getGeminiGenerationConfig(body) { + if (body.request && typeof body.request === "object") { + if (!body.request.generationConfig || typeof body.request.generationConfig !== "object") { + body.request.generationConfig = {}; + } + return body.request.generationConfig; + } + if (!body.generationConfig || typeof body.generationConfig !== "object") { + body.generationConfig = {}; + } + return body.generationConfig; +} + function setGeminiThinking(body, tc) { - const gc = body.request?.generationConfig - ? body.request.generationConfig - : (body.generationConfig && typeof body.generationConfig === "object" - ? body.generationConfig - : (body.generationConfig = {})); + const gc = getGeminiGenerationConfig(body); gc.thinkingConfig = tc; } +function ensureGeminiOutputFloor(body, floor, caps) { + const cap = Number.isFinite(caps?.maxOutput) ? caps.maxOutput : floor; + const target = Math.min(floor, cap); + const gc = getGeminiGenerationConfig(body); + const current = Number(gc.maxOutputTokens); + if (!Number.isFinite(current) || current < target) { + gc.maxOutputTokens = target; + } +} + // Strip every known thinking field from a body (used before re-applying / when unsupported). function stripAll(body) { delete body.thinking; @@ -158,7 +222,7 @@ function stripAll(body) { } // Apply unified thinking config to body in the resolved provider-native format. -function applyFormat(fmt, body, cfg, caps) { +function applyFormat(fmt, body, cfg, caps, supportedLevels) { const none = cfg.mode === "none"; const canDisable = caps.thinkingCanDisable !== false; // Model cannot disable thinking → clamp "none" to minimal effort instead. @@ -168,11 +232,17 @@ function applyFormat(fmt, body, cfg, caps) { case "openai": { if (none && canDisable) { body.reasoning_effort = "none"; break; } const level = toLevel(eff); - if (level) body.reasoning_effort = level; + if (level) body.reasoning_effort = normalizeOpenAILevel(level, supportedLevels); break; } case "claude-adaptive": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + // output_config.effort alone does NOT turn thinking on: Anthropic requires + // an explicit thinking:{type:"adaptive"} on Opus 4.6/4.7/4.8 and Sonnet 4.6 + // ("thinking is off unless you explicitly set it"), and Anthropic-compatible + // shims (e.g. GitHub Copilot /v1/messages) default thinking off even for + // Sonnet 5. Send both fields — the documented adaptive-thinking shape. + body.thinking = { type: "adaptive" }; const level = toLevel(eff); body.output_config = { effort: level === "xhigh" ? "high" : level }; break; @@ -186,12 +256,14 @@ function applyFormat(fmt, body, cfg, caps) { case "gemini-level": { const level = none ? "minimal" : toGeminiThinkingLevel(eff); setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" }); + ensureGeminiOutputFloor(body, geminiLevelOutputFloor(level), caps); break; } case "gemini-budget": { if (none && canDisable) { setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false }); break; } const budget = toBudget(eff, caps.thinkingRange); setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true }); + ensureGeminiOutputFloor(body, geminiBudgetOutputFloor(budget ?? -1), caps); break; } case "zai": { @@ -217,8 +289,8 @@ function applyFormat(fmt, body, cfg, caps) { } case "kimi": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } - const level = toLevel(eff); - if (level) body.reasoning_effort = level === "max" ? "high" : level; + const effort = toKimiReasoningEffort(eff); + if (effort) body.reasoning_effort = effort; break; } case "minimax": { @@ -238,6 +310,15 @@ function applyFormat(fmt, body, cfg, caps) { if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level; break; } + case "tokenrouter": { + // TokenRouter's reasoning_effort enum is low/medium/high/xhigh/max — it rejects + // "none"/"auto" with a 400 and supports "max" natively (no clamp like openai). + // "none" → omit the field so the upstream default applies; pass levels through. + if (none || eff.mode === "auto") break; + const level = toLevel(eff); + if (level) body.reasoning_effort = level; + break; + } case "kiro": // Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here. break; @@ -265,7 +346,8 @@ export function applyThinking(targetFormat, model, body, provider = null, intent if (!cfg) return body; const fmt = resolveFormat(targetFormat, cleanModel, provider); + const supportedLevels = getThinkingLevels(provider, cleanModel); stripAll(body); - applyFormat(fmt, body, cfg, caps); + applyFormat(fmt, body, cfg, caps, supportedLevels); return body; } diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index ec6e6c47..555314dc 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -9,6 +9,9 @@ import { PROVIDERS } from "../../providers/index.js"; import { getCapabilitiesForModel } from "../../providers/capabilities.js"; import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; +const CACHE_CONTROL_5M = { type: "ephemeral" }; +const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" }; + // Check if message has valid non-empty content export function hasValidContent(msg) { if (typeof msg.content === "string" && msg.content.trim()) return true; @@ -16,7 +19,9 @@ export function hasValidContent(msg) { return msg.content.some(block => (block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) || block.type === CLAUDE_BLOCK.TOOL_USE || - block.type === CLAUDE_BLOCK.TOOL_RESULT + block.type === CLAUDE_BLOCK.TOOL_RESULT || + block.type === CLAUDE_BLOCK.IMAGE || + block.type === CLAUDE_BLOCK.DOCUMENT ); } return false; @@ -122,31 +127,128 @@ export function normalizeClaudePassthrough(body, model = "") { if (Object.keys(body.output_config).length === 0) delete body.output_config; } - // 2. Hoist mid-conversation system messages into the top-level system field + // 2. Fold mid-conversation system messages into the neighbouring turn. + // Hoisting them into body.system would insert volatile content (token counters, + // reminders) ahead of the whole conversation and invalidate the prefix cache on + // every request. Folding in place keeps the cached prefix stable. if (Array.isArray(body.messages)) { - const systemBlocks = []; const messages = []; for (const msg of body.messages) { - if (msg.role === ROLE.SYSTEM) { - const text = typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n") - : ""; - if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text }); + if (msg.role !== ROLE.SYSTEM) { + messages.push(msg); continue; } - messages.push(msg); + const text = typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n") + : ""; + if (!text.trim()) continue; + + // Copy-on-write: the caller's body is reused across account-fallback + // attempts, so folding must never mutate the original message. + const block = { type: CLAUDE_BLOCK.TEXT, text }; + const prev = messages[messages.length - 1]; + if (prev?.role === ROLE.USER) { + const content = typeof prev.content === "string" + ? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }] + : Array.isArray(prev.content) ? [...prev.content] : []; + messages[messages.length - 1] = { ...prev, content: [...content, block] }; + continue; + } + messages.push({ role: ROLE.USER, content: [block] }); + } + body.messages = messages; + } + + // 3. Drop thinking blocks whose signature is not Claude's (combo mixes models, + // so foreign signatures leak into history and Anthropic rejects them). + const thinkingEnabled = body.thinking?.type === "enabled"; + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue; + let hasToolUse = false; + let hasKeptThinking = false; + const kept = []; + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) { + if (isValidClaudeSignature(block.signature)) { + hasKeptThinking = true; + kept.push(block); + } + continue; + } + if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true; + kept.push(block); + } + msg.content = kept; + if (thinkingEnabled && !hasKeptThinking && hasToolUse) { + msg.content.unshift(buildThinkingPlaceholder("claude")); + } + } + } + + return body; +} + +// Put a 5m breakpoint on the last cache-eligible block of a message. +// thinking/redacted_thinking blocks do not accept cache_control. +function markLastCacheableBlock(msg) { + if (!Array.isArray(msg?.content)) return false; + for (let i = msg.content.length - 1; i >= 0; i--) { + const block = msg.content[i]; + if (typeof block !== "object" || block === null) continue; + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue; + block.cache_control = { ...CACHE_CONTROL_5M }; + return true; + } + return false; +} + +// Re-anchor cache breakpoints on a Claude passthrough body (same policy as +// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m. +// The client's own markers point at pre-normalization offsets, so they are dropped. +// Must run LAST, after every step that can reshape system/tools/messages +// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail. +export function anchorClaudeCache(body) { + if (!body || typeof body !== "object") return body; + + if (Array.isArray(body.system)) { + const last = body.system.length - 1; + body.system.forEach((block, i) => { + if (typeof block !== "object" || block === null) return; + if (i === last) block.cache_control = { ...CACHE_CONTROL_1H }; + else delete block.cache_control; + }); + } + + if (Array.isArray(body.tools)) { + const last = body.tools.length - 1; + body.tools.forEach((tool, i) => { + if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H }; + else delete tool.cache_control; + }); + } + + if (Array.isArray(body.messages)) { + let anchored = null; + for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i]; + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) delete block.cache_control; + + // Prefer the last assistant turn: it ends a completed exchange, so the + // prefix up to it stays byte-stable across the following requests. + if (anchored || msg.role !== ROLE.ASSISTANT) continue; + anchored = markLastCacheableBlock(msg); } - if (systemBlocks.length > 0) { - const existing = Array.isArray(body.system) - ? body.system - : typeof body.system === "string" && body.system.trim() - ? [{ type: "text", text: body.system }] - : []; - body.system = [...existing, ...systemBlocks]; - body.messages = messages; + // First turn of a conversation has no assistant yet — anchor the final + // message instead, so the opening prompt is cached rather than paid twice. + if (!anchored) { + for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) { + anchored = markLastCacheableBlock(body.messages[i]); + } } } @@ -192,10 +294,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne delete body.output_config; } - // Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS) + // Clamp max_tokens to the model's real output ceiling. Models whose caps + // declare a higher maxOutput (e.g. Opus 4.8 / Sonnet 4.6 = 128000) are allowed + // up to it, so max-effort thinking gets full budget; others fall back to the + // conservative 64000 default. if (body.max_tokens) { - const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS); + const ceiling = getCapabilitiesForModel(provider, body.model).maxOutput || DEFAULT_MAX_TOKENS; if (body.max_tokens > ceiling) body.max_tokens = ceiling; + + // Reconcile against thinking budget. applyThinking (thinkingUnified.js) runs + // AFTER adjustMaxTokens capped max_tokens, and the claude-budget format maps + // max effort → budget_tokens 128000 — larger than the clamped max_tokens. + // Anthropic requires max_tokens strictly greater than budget_tokens (else 400). + // Prefer raising max_tokens to preserve the requested thinking depth; if the + // budget alone meets/exceeds the ceiling, cap output and shrink the budget so + // some tokens remain for the answer. + if (body.thinking?.type === "enabled" && body.thinking.budget_tokens && body.thinking.budget_tokens >= body.max_tokens) { + body.max_tokens = Math.min(body.thinking.budget_tokens + 1024, ceiling); + if (body.thinking.budget_tokens >= body.max_tokens) { + body.thinking.budget_tokens = Math.max(1024, body.max_tokens - 1024); + } + } } // 1. System: remove all cache_control, add only to last block with ttl 1h diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index bf6c4586..6393a78b 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -7,7 +7,13 @@ import { OPENAI_BLOCK } from "../schema/index.js"; export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ // Basic constraints (not supported by Gemini API) "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", - "minItems", "maxItems", "format", + "minItems", "maxItems", "format", "multipleOf", + // Array keywords the Gemini schema proto has no field for. Agent tool + // schemas set these routinely, and one occurrence rejects the whole request + // with "Unknown name ...: Cannot find field". + "uniqueItems", "contains", + // 2020-12 keywords with no Gemini equivalent + "unevaluatedProperties", "unevaluatedItems", "contentSchema", // Claude rejects these in VALIDATED mode "default", "examples", // JSON Schema meta keywords @@ -353,6 +359,19 @@ export function cleanJSONSchemaForAntigravity(schema) { function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; + // Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder + if (Object.keys(obj).length === 0) { + obj.type = "object"; + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + return; + } + if (obj.type === "object") { if (!obj.properties || Object.keys(obj.properties).length === 0) { obj.properties = { diff --git a/open-sse/translator/formats/maxTokens.js b/open-sse/translator/formats/maxTokens.js index 0e5b36f2..4d2cd209 100644 --- a/open-sse/translator/formats/maxTokens.js +++ b/open-sse/translator/formats/maxTokens.js @@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf /** * Adjust max_tokens based on request context * @param {object} body - Request body + * @param {number} [ceiling=DEFAULT_MAX_TOKENS] - Upper bound for max_tokens. + * Callers with model context (e.g. openai-to-claude) pass the model's real + * maxOutput so high-output models (Opus 4.8 = 128000) aren't pre-clamped to + * the conservative 64000 default before the model-aware step sees them. * @returns {number} Adjusted max_tokens */ -export function adjustMaxTokens(body) { +export function adjustMaxTokens(body, ceiling = DEFAULT_MAX_TOKENS) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; // Auto-increase for tool calling to prevent truncated arguments (min never above max) @@ -16,14 +20,14 @@ export function adjustMaxTokens(body) { } // Ensure max_tokens > thinking.budget_tokens (Claude API requirement) - // Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS - // which could equal budget_tokens when budget_tokens >= 64000 + // Claude API requires strictly greater, so add buffer instead of using the + // ceiling which could equal budget_tokens when budget_tokens >= ceiling if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) { maxTokens = body.thinking.budget_tokens + 1024; } - // Never exceed the global ceiling - if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS; + // Never exceed the ceiling + if (maxTokens > ceiling) maxTokens = ceiling; return maxTokens; } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e9c84971..e2f45339 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Always ensure tool_calls have id (some providers require it) ensureToolCallIds(result); - // Fix missing tool responses (insert empty tool_result if needed) - fixMissingToolResponses(result); + // Kiro performs stricter source-aware reconciliation after session replay. + // The generic helper inserts OpenAI `role: tool` messages, which a direct + // Claude→Kiro translator cannot consume and which cannot repair partial + // parallel tool results. + if (targetFormat !== FORMATS.KIRO) { + fixMissingToolResponses(result); + } // Capture thinking intent from the original (pre-translation) body, before any // format conversion strips/renames the fields. Applied after translation. @@ -103,8 +108,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream } } - // Normalize thinking to the target provider-native format (config-driven, capability-aware) - applyThinking(targetFormat, model, result, provider, thinkingIntent); + // Normalize thinking to the target provider-native format (config-driven, capability-aware). + // Kiro's GenerateAssistantResponse request does not accept the generic top-level + // `thinking` field; its translators map thinking intent to KAS-compatible + // systemPrompt/additionalModelRequestFields instead. + const kiroThinkingMappedByTranslator = + targetFormat === FORMATS.KIRO && + (sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.CLAUDE); + if (!kiroThinkingMappedByTranslator) { + applyThinking(targetFormat, model, result, provider, thinkingIntent); + } // Always normalize to clean OpenAI format when target is OpenAI // This handles hybrid requests (e.g., OpenAI messages + Claude tools) @@ -245,8 +258,10 @@ export function initState(sourceFormat) { funcArgsBuf: {}, funcNames: {}, funcCallIds: {}, + funcItemAdded: {}, funcArgsDone: {}, funcItemDone: {}, + customToolNames: new Set(), completedSent: false }; } diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 5d891c11..972b0610 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -6,17 +6,10 @@ * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the * claude→openai→kiro pivot. * - * It reproduces the two 400-guards that live in openai-to-kiro.js so that a - * Claude client which omits the `tools` array on a follow-up turn (typical - * after client-side compaction) does not trip Kiro's schema validator and get - * "Improperly formed request" (HTTP 400): - * - * 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse - * every tool_use / tool_result block to plain text so no structured tool - * reference survives to trigger the "tools required" rule. - * 2. reconcileOrphanedToolResults — when tools ARE present, fold any - * tool_result whose tool_use_id has no matching tool_use back into the - * user text instead of leaving a dangling structured reference. + * After session replay it delegates to the shared Kiro conversation + * canonicalizer. That layer enforces adjacent one-to-one tool use/results, + * repairs partial parallel calls, and flattens compacted structured references + * that can no longer be represented safely. * * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and * the `enabled` reasoning trigger, matching @@ -24,92 +17,31 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { v4 as uuidv4 } from "uuid"; +import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; +import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, resolveDefaultProfileArn, + buildKiroAdditionalModelRequestFieldsForModel, + usesKiroNativeGptEffort, } from "../../config/kiroConstants.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Stringify a tool_use input as a readable line. */ -function toolUseToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a Claude tool_result block's content as a readable line. */ -function toolResultBlockToText(content) { - let text = ""; - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - text = content - .map((c) => (typeof c === "string" ? c : c?.text || "")) - .filter(Boolean) - .join("\n"); - } else if (content) { - try { - text = JSON.stringify(content); - } catch { - text = ""; - } - } - return `[Tool result: ${text}]`; -} - -/** - * When the client sent no tools, rewrite every tool_use (assistant) and - * tool_result (user) content block into plain text. Keeps text + images. - * Returns a new messages array; never mutates the input. - */ -function flattenClaudeToolInteractions(messages) { - const out = []; - for (const msg of messages) { - if (!msg) continue; - - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - const parts = []; - for (const block of msg.content) { - if (block.type === CLAUDE_BLOCK.TEXT && block.text) { - parts.push(block.text); - } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolUseToText(block.name, block.input)); - } - } - out.push({ ...msg, content: parts.join("\n") }); - continue; - } - - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map((block) => - block.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) } - : block - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - return out; -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Convert Claude messages to Kiro history + currentMessage. * Kiro requires alternating user/assistant turns; consecutive same-role * messages are merged. */ -function convertClaudeMessagesToKiro(messages, tools, model) { +function convertClaudeMessagesToKiro(messages, model) { const history = []; let currentMessage = null; @@ -118,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjected = false; - - const clientProvidedTools = Array.isArray(tools) && tools.length > 0; - - const buildToolSpecs = () => - tools.map((t) => { - const name = t.name; - const description = t.description || `Tool: ${name}`; - const schema = t.input_schema || {}; - const normalizedSchema = - Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema }, - }, - }; - }); const flushPending = () => { if (currentRole === ROLE.USER) { @@ -153,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { toolResults: pendingToolResults, }; } - // Attach tools to the first user turn only. - if (clientProvidedTools && !toolsInjected) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs(); - toolsInjected = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -205,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: resultContent }], }); } @@ -252,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } } - // Grab tools from the first history user turn before cleanup strips them. - const firstHistoryTools = - history[0]?.userInputMessage?.userInputMessageContext?.tools; - history.forEach((item) => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if ( item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0 @@ -303,95 +198,40 @@ function convertClaudeMessagesToKiro(messages, tools, model) { currentMessage = { userInputMessage: { content: "", modelId: model } }; } - // Inject tools into currentMessage after cleanup if not already present. - if ( - firstHistoryTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryTools; - } - return { history: mergedHistory, currentMessage }; } -/** - * Fold orphaned toolResults (those whose toolUseId has no matching toolUse in - * any assistant turn) back into the user text, removing the dangling - * structured reference that makes Kiro 400. - */ -function reconcileOrphanedToolResults(history, currentMessage) { - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - const text = Array.isArray(tr.content) - ? tr.content.map((c) => c?.text || "").join("\n") - : ""; - salvaged.push(`[Tool result: ${text}]`); - } - } - - if (salvaged.length === 0) continue; - - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } +function extractClaudeSystemText(system) { + if (!system) return ""; + if (typeof system === "string") return system; + if (Array.isArray(system)) { + return system.map((s) => { + if (typeof s === "string") return s; + return s?.text || ""; + }).filter(Boolean).join("\n"); } + return ""; } /** * Build a Kiro payload directly from a Claude Messages API request body. */ export function claudeToKiroRequest(model, body, stream, credentials) { - let messages = Array.isArray(body.messages) ? body.messages : []; + const messages = Array.isArray(body.messages) ? body.messages : []; const tools = Array.isArray(body.tools) ? body.tools : []; - const clientProvidedTools = tools.length > 0; const maxTokens = body.max_tokens || 32000; const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - // Guard 1: no client tools → flatten all tool interactions to text. - if (!clientProvidedTools) { - messages = flattenClaudeToolInteractions(messages); - } - - const { history, currentMessage } = convertClaudeMessagesToKiro( - messages, - tools, - upstreamModel - ); - - // Guard 2: tools present → reconcile dangling tool_results. - if (clientProvidedTools) { - reconcileOrphanedToolResults(history, currentMessage); - } + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel); // api_key / idc / external_idp must never use the shared default ARN (belongs // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. @@ -402,50 +242,95 @@ export function claudeToKiroRequest(model, body, stream, credentials) { ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); - let finalContent = currentMessage?.userInputMessage?.content || ""; - - // System prompt → prepend to the user content. - if (body.system) { - let systemText = ""; - if (typeof body.system === "string") { - systemText = body.system; - } else if (Array.isArray(body.system)) { - systemText = body.system.map((s) => s.text || "").join("\n"); - } - if (systemText) finalContent = `${systemText}\n\n${finalContent}`; - } - - // Prefix order: thinking_mode tag, timestamp marker, then agentic prompt. + // Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a + // content fallback too because the CodeWhisperer surface does not always + // enforce top-level systemPrompt for direct calls. const timestamp = new Date().toISOString(); - const prefixParts = []; - if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget)); - prefixParts.push(`[Context: Current time is ${timestamp}]`); - if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); - finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`; + const systemPromptParts = []; + if (thinkingBudget !== null && !usesNativeGptEffort) { + systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); + } + if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); + const systemInstruction = extractClaudeSystemText(body.system); + if (systemInstruction) systemPromptParts.push(systemInstruction); + const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n"); + const currentTimeContext = `[Context: Current time is ${timestamp}]`; + const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n"); + + const sessionIdentity = resolveSessionIdentity({ + headers: credentials?.rawHeaders, + body, + connectionId: credentials?.connectionId, + scope: "kiro", + }); + const conversationId = sessionIdentity.sessionId; + const continuationId = resolveContinuationId({ + sessionId: conversationId, + connectionId: credentials?.connectionId, + scope: "kiro", + ephemeral: sessionIdentity.ephemeral, + }); + const replay = applyKiroSessionReplay({ + conversationId, + connectionId: credentials?.connectionId, + modelId: upstreamModel, + systemPrompt, + contentPrefix, + currentContentPrefix: currentTimeContext, + history, + currentMessage, + }); + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + // canonicalizeKiroConversation() already ran its second-chance repair (flatten + // every structured tool turn to text, then re-validate). A body that is STILL + // invalid here cannot be made shippable, and Kiro answers it with + // 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}. + // Fail locally instead: chatCore turns a falsy return into a 400 without + // spending an upstream call or a per-account cooldown. The taxonomy + // (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending + // turn so the shape can be diagnosed from the log alone. + if (!canonical.valid) { + console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`); + return null; + } + const replayCurrent = canonical.currentMessage.userInputMessage; + const userInputMessage = { + content: replayCurrent.content || "", + modelId: upstreamModel, + origin: "AI_EDITOR", + ...(replayCurrent.userInputMessageContext && { + userInputMessageContext: replayCurrent.userInputMessageContext, + }), + ...(replayCurrent.images && { + images: replayCurrent.images, + }), + }; const payload = { conversationState: { chatTriggerType: "MANUAL", - conversationId: uuidv4(), + conversationId, + agentContinuationId: continuationId, + agentTaskType: "vibe", currentMessage: { - userInputMessage: { - content: finalContent, - modelId: upstreamModel, - origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: - currentMessage.userInputMessage.userInputMessageContext, - }), - ...(currentMessage?.userInputMessage?.images && { - images: currentMessage.userInputMessage.images, - }), - }, + userInputMessage, }, - history, + history: canonical.history, }, + agentMode: "vibe", }; if (profileArn) payload.profileArn = profileArn; + if (systemPrompt) payload.systemPrompt = systemPrompt; + if (additionalModelRequestFields) { + payload.additionalModelRequestFields = additionalModelRequestFields; + } if (maxTokens || temperature !== undefined || topP !== undefined) { payload.inferenceConfig = {}; diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index c6e92ed6..3956f828 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -129,14 +129,15 @@ function fixMissingToolResponsesOpenAI(messages) { } } -// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400) +// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400). +// Uses tags that Claude models treat as authoritative directives. function systemReminderText(content) { const parts = Array.isArray(content) ? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "") : [typeof content === "string" ? content : ""]; const text = parts.filter(Boolean).join("\n"); if (!text.trim()) return ""; - return `\n${text}\n`; + return `\n${text}\n`; } // Convert single Claude message - returns single message or array of messages diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 98c516cd..29e43152 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -31,11 +31,14 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) let currentAssistantMsg = null; let pendingToolResults = []; let pendingReasoning = ""; + let pendingReasoningEncrypted = ""; + const additionalTools = []; + const customToolNames = new Set(); const inputItems = normalizeResponsesInput(body.input); if (!inputItems) return body; - // Extract reasoning text from summary[].text or encrypted_content fallback + // Extract reasoning text from summary[].text (encrypted_content is continuity-only) const extractReasoningText = (item) => { if (Array.isArray(item.summary)) { const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n"); @@ -48,6 +51,13 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) return ""; }; + const attachPendingReasoning = (msg) => { + if (pendingReasoning) msg.reasoning_content = pendingReasoning; + if (pendingReasoningEncrypted) msg.encrypted_content = pendingReasoningEncrypted; + pendingReasoning = ""; + pendingReasoningEncrypted = ""; + }; + for (const item of inputItems) { // Determine item type - Droid CLI sends role-based items without 'type' field // Fallback: if no type but has role property, treat as message @@ -80,14 +90,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) }) : item.content; const msg = { role: item.role, content }; - // Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode) - if (item.role === ROLE.ASSISTANT && pendingReasoning) { - msg.reasoning_content = pendingReasoning; + // Attach buffered reasoning to assistant turn (required by xiaomi-mimo + store=false continuity) + if (item.role === ROLE.ASSISTANT) attachPendingReasoning(msg); + else { + pendingReasoning = ""; + pendingReasoningEncrypted = ""; } - pendingReasoning = ""; result.messages.push(msg); } - else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) { // Start or append to assistant message with tool_calls if (!currentAssistantMsg) { currentAssistantMsg = { @@ -95,23 +106,24 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) content: null, tool_calls: [] }; - if (pendingReasoning) { - currentAssistantMsg.reasoning_content = pendingReasoning; - pendingReasoning = ""; - } + attachPendingReasoning(currentAssistantMsg); } // Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444) if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; + if (itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) customToolNames.add(item.name); + const toolInput = itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL + ? { input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? "") } + : item.arguments; currentAssistantMsg.tool_calls.push({ id: item.call_id, type: OPENAI_BLOCK.FUNCTION, function: { name: item.name, - arguments: item.arguments + arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {}) } }); } - else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT) { // Flush assistant message first if exists if (currentAssistantMsg) { result.messages.push(currentAssistantMsg); @@ -131,10 +143,19 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) }); } + else if (itemType === RESPONSES_ITEM.ADDITIONAL_TOOLS) { + if (Array.isArray(item.tools)) additionalTools.push(...item.tools); + } else if (itemType === RESPONSES_ITEM.REASONING) { - // Buffer reasoning text; attached to next assistant message/function_call + // Buffer reasoning text; attached to next assistant message/function_call. + // Also stash encrypted_content so a later openai→responses hop can restore + // the store=false continuity blob (Grok CLI / Codex multi-turn). const txt = extractReasoningText(item); if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt; + if (typeof item.encrypted_content === "string" && item.encrypted_content) { + // Prefer attaching to the next assistant message we create + pendingReasoningEncrypted = item.encrypted_content; + } continue; } } @@ -154,15 +175,45 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // explicit `name` field and cannot be represented as Chat Completions function declarations. // Filter them out to avoid sending nameless functionDeclarations to downstream providers // such as Gemini, which strictly validates function names. - if (body.tools && Array.isArray(body.tools)) { - result.tools = body.tools + const responseTools = [ + ...(Array.isArray(body.tools) ? body.tools : []), + ...additionalTools, + ]; + if (responseTools.length > 0) { + result.tools = responseTools .map(tool => { // Already in Chat Completions format: { type: "function", function: { name, ... } } if (tool.function) return tool; - // Responses API function tool: { type: "function", name, description, parameters } - // Only convert when a non-empty name is present; skip hosted tools without one. + // Responses API function/custom tool: { type, name, description, parameters|format }. + // Chat Completions has no freeform custom-tool declaration, so expose custom + // tools as functions with one raw `input` string while retaining their names + // in translator-only metadata for the response conversion. const name = tool.name; if (!name || typeof name !== "string" || name.trim() === "") return null; + if (tool.type === "custom") { + customToolNames.add(name); + const formatHint = [tool.format?.syntax, tool.format?.definition].filter(Boolean).join("\n"); + return { + type: OPENAI_BLOCK.FUNCTION, + function: { + name, + description: [String(tool.description || ""), formatHint].filter(Boolean).join("\n\n"), + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw freeform input for this custom tool" + } + }, + required: ["input"], + additionalProperties: false + } + } + }; + } + // Responses API function tool: { type: "function", name, description, parameters } + // Only convert when a non-empty name is present; skip hosted tools without one. return { type: OPENAI_BLOCK.FUNCTION, function: { @@ -175,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) }) .filter(Boolean); } + if (customToolNames.size > 0) result._customToolNames = [...customToolNames]; // Cleanup Responses API specific fields // Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream) @@ -188,7 +240,11 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) delete result.include; delete result.prompt_cache_key; delete result.store; + if (typeof result.reasoning?.effort === "string") { + result.reasoning_effort = result.reasoning.effort; + } delete result.reasoning; + delete result.client_metadata; return result; } @@ -202,6 +258,43 @@ function normalizeToolParameters(params) { return params; } +/** + * Build a Responses `reasoning` input item from Chat Completions assistant fields. + * Preserves encrypted blobs needed by store=false multi-turn (Grok CLI / Codex). + * Returns null when the message has nothing useful to re-send. + */ +function buildReasoningInputItem(msg) { + if (!msg || typeof msg !== "object") return null; + + const encrypted = + (typeof msg.encrypted_content === "string" && msg.encrypted_content) || + (typeof msg.reasoning_encrypted_content === "string" && msg.reasoning_encrypted_content) || + (typeof msg.reasoning?.encrypted_content === "string" && msg.reasoning.encrypted_content) || + ""; + + let summaryText = ""; + if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) { + summaryText = msg.reasoning_content; + } else if (typeof msg.reasoning === "string" && msg.reasoning.trim()) { + summaryText = msg.reasoning; + } else if (Array.isArray(msg.reasoning_details)) { + summaryText = msg.reasoning_details + .map((d) => (typeof d?.text === "string" ? d.text : typeof d?.content === "string" ? d.content : "")) + .filter(Boolean) + .join("\n"); + } + + if (!encrypted && !summaryText) return null; + + const item = { type: RESPONSES_ITEM.REASONING }; + if (summaryText) { + item.summary = [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: summaryText }]; + } + // encrypted_content is the continuity token for store=false backends + if (encrypted) item.encrypted_content = encrypted; + return item; +} + /** * Convert OpenAI Chat Completions to OpenAI Responses API format */ @@ -221,17 +314,26 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) const messages = body.messages || []; for (const msg of messages) { - if (msg.role === ROLE.SYSTEM) { - // Use first system message as instructions + if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) { + // Use the first instruction-bearing message as instructions. + // OpenAI recommends role="developer" for GPT-5/Codex as the system-level prompt. if (!hasSystemMessage) { result.instructions = typeof msg.content === "string" ? msg.content : ""; hasSystemMessage = true; } - continue; // Skip system messages in input + continue; // Skip instruction messages in input } // Convert user/assistant messages to input items if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) { + // Multi-turn continuity for store=false Responses backends (Codex / Grok CLI): + // re-emit a reasoning item before the assistant message when the chat-format + // history carried reasoning text and/or encrypted_content from a prior turn. + if (msg.role === ROLE.ASSISTANT) { + const reasoningItem = buildReasoningInputItem(msg); + if (reasoningItem) result.input.push(reasoningItem); + } + const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT; const content = typeof msg.content === "string" ? [{ type: contentType, text: msg.content }] @@ -318,6 +420,8 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) if (body.top_p !== undefined) result.top_p = body.top_p; if (body.reasoning !== undefined) result.reasoning = body.reasoning; if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; + if (body.service_tier !== undefined) result.service_tier = body.service_tier; + if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key; return result; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index bc73149b..580debfe 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js"; import { parseDataUri } from "../concerns/image.js"; import { extractTextContent } from "../formats/gemini.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; // Empty prefix matches real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. @@ -15,9 +16,13 @@ const CLAUDE_OAUTH_TOOL_PREFIX = ""; export function openaiToClaudeRequest(model, body, stream) { // Tool name mapping for Claude OAuth (capitalizedName → originalName) const toolNameMap = new Map(); + // Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000), + // not the conservative 64000 default — otherwise a high-output model is + // pre-clamped here before prepareClaudeRequest's model-aware step runs. + const modelCeiling = getCapabilitiesForModel(null, model).maxOutput || undefined; const result = { model: model, - max_tokens: adjustMaxTokens(body), + max_tokens: adjustMaxTokens(body, modelCeiling), stream: stream }; @@ -148,7 +153,15 @@ Respond ONLY with the JSON object, no other text.`); continue; } - const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool; + // Function-shaped tools arrive in two flavors from real clients: + // (a) openai-spec: { type: "function", function: { name, ... } } + // (b) legacy/loose: { function: { name, ... } } (no parent `type`) + // Both must yield toolData.name = "echo". Treat the bare-function shape + // as a function tool too — Anthropic-compatible gateways (notably + // MiniMax M3 at api.minimaxi.com) reject payloads where this branch + // falls through with `toolData.name === undefined`, returning their + // upstream code (2013) "invalid tool type". See #2435. + const toolData = tool.function ?? tool; const originalName = toolData.name; // Claude OAuth requires prefixed tool names to avoid conflicts diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index afb3effc..9e029e2c 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -1,7 +1,6 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; -import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js"; function generateUUID() { return crypto.randomUUID(); @@ -282,31 +281,17 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra // Antigravity specific fields if (isAntigravity) { envelope.requestType = "agent"; - - // Inject required default system prompt for Antigravity - // Inject required default system prompt for Antigravity (double injection) - const systemParts = [ - { text: ANTIGRAVITY_DEFAULT_SYSTEM }, - { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } - ]; - - if (envelope.request.systemInstruction?.parts) { - envelope.request.systemInstruction.parts.unshift(...systemParts); - } else { - envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; - } - - // Add toolConfig for Antigravity - if (geminiCLI.tools?.length > 0) { - envelope.request.toolConfig = { - functionCallingConfig: { mode: "VALIDATED" } - }; - } } else { // Keep safetySettings for Gemini CLI envelope.request.safetySettings = geminiCLI.safetySettings; } + if (geminiCLI.tools?.length > 0) { + envelope.request.toolConfig = { + functionCallingConfig: { mode: "VALIDATED" } + }; + } + return envelope; } @@ -414,12 +399,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } } - // Add system instruction (Antigravity default - double injection + user system prompt) - const systemParts = [ - { text: ANTIGRAVITY_DEFAULT_SYSTEM }, - { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } - ]; - + const systemParts = []; // Merge user system prompt from claudeRequest if (claudeRequest.system) { if (Array.isArray(claudeRequest.system)) { @@ -431,10 +411,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } } - // Merge existing systemInstruction parts (from contents conversion) - if (envelope.request.systemInstruction?.parts) { - envelope.request.systemInstruction.parts.unshift(...systemParts); - } else { + if (systemParts.length > 0) { envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; } @@ -463,4 +440,3 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null); register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null); register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null); - diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index ee886666..3e30d2f5 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -5,159 +5,25 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { v4 as uuidv4 } from "uuid"; -import { resolveSessionId } from "../../utils/sessionManager.js"; +import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; +import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, - resolveDefaultProfileArn + resolveDefaultProfileArn, + buildKiroAdditionalModelRequestFieldsForModel, + usesKiroNativeGptEffort } from "../../config/kiroConstants.js"; import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Render a single tool call as a readable text line. */ -function toolCallToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a tool result (string or content-block array) as a text line. */ -function toolResultToText(content) { - const text = Array.isArray(content) - ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") - : (typeof content === "string" ? content : ""); - return `[Tool result: ${text}]`; -} - -/** - * Flatten all tool calls/results in a conversation into plain text. - * - * Kiro's schema validator requires a non-empty - * currentMessage.userInputMessageContext.tools array whenever the history - * references any tool use; otherwise it returns "Improperly formed request" - * (HTTP 400). A client can hit this by omitting the `tools` array on a - * follow-up request — typically after client-side compaction (e.g. OpenCode). - * - * Rather than fabricate stub tool specs — which would advertise tool-calling - * capability the client never requested and may not handle, risking a phantom - * tool call on an otherwise plain turn — we collapse the tool interaction into - * text. The request stays honest, and since no structured tool content - * remains, the validator's "tools required" rule never fires. - * - * Only invoked when the client did NOT send tools; when tools are present the - * structured form is preserved. - */ -function flattenToolInteractions(messages) { - const out = []; - - for (const msg of messages) { - // OpenAI tool-result message → user text line - if (msg.role === ROLE.TOOL) { - out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); - continue; - } - - if (msg.role === ROLE.ASSISTANT) { - const parts = []; - if (Array.isArray(msg.content)) { - for (const c of msg.content) { - if (c.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolCallToText(c.name, c.input)); - } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { - parts.push(c.text || ""); - } - } - } else if (typeof msg.content === "string") { - parts.push(msg.content); - } - for (const tc of msg.tool_calls || []) { - parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); - } - out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); - continue; - } - - // User messages: replace tool_result blocks with text, keep text + images. - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map(c => - c.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } - : c - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - - return out; -} - -/** - * Reconcile orphaned toolResults — those whose toolUseId has no matching - * toolUse in any assistant message. This happens when client-side compaction - * truncates the conversation and removes the assistant message containing the - * tool_use, but keeps the user message with the corresponding tool_result. - * - * A dangling structured reference makes Kiro return 400, so it must be removed. - * But the client deliberately kept the result content through compaction, so - * rather than discard it we fold it back into the user message as text — the - * same shape flattenToolInteractions() produces. The 400 trigger (the - * structured reference) is gone; the content survives. - * - * `messages` is every carrier that can hold toolResults — both history items - * and the popped-out currentMessage (orphans can land on either). - */ -function reconcileOrphanedToolResults(history, currentMessage) { - // Phase 1: collect all valid toolUseIds from assistant messages in history. - // (currentMessage is always a user turn, so it carries no toolUses.) - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - // Phase 2: across history + currentMessage, keep results with a matching - // toolUse and salvage the rest as text. - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - salvaged.push(toolResultToText(tr.content)); - } - } - - if (salvaged.length === 0) continue; // no orphans — leave untouched - - // Fold orphaned result content into the user text so it is not lost - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Safely parse JSON string, returning fallback on failure. @@ -173,26 +39,15 @@ function safeJSONParse(str, fallback) { * * Returns { history, currentMessage }. */ -function convertMessages(messages, tools, model) { +function convertMessages(messages, model) { let history = []; let currentMessage = null; - const clientProvidedTools = tools && tools.length > 0; - - // When the client did not send tools, flatten any tool calls/results in the - // history into plain text (see flattenToolInteractions). This keeps the - // request honest and sidesteps Kiro's "tools required" 400, since no - // structured tool content survives to trigger it. - if (!clientProvidedTools) { - messages = flattenToolInteractions(messages); - } - let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -215,39 +70,6 @@ function convertMessages(messages, tools, model) { }; } - // Add tools to the user message that has no preceding assistant messages, - // OR the first user message (whichever comes first after any opening - // assistant messages). We track whether any user message has already - // received tools via a flag on the history array. - if (clientProvidedTools && !toolsInjectedToFirstUserMsg) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { - const name = t.function?.name || t.name; - let description = t.function?.description || t.description || ""; - - if (!description.trim()) { - description = `Tool: ${name}`; - } - - const schema = t.function?.parameters || t.parameters || t.input_schema || {}; - // Normalize schema: Kiro requires required[] and proper type/properties - const normalizedSchema = Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema } - } - }; - }); - toolsInjectedToFirstUserMsg = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -270,6 +92,7 @@ function convertMessages(messages, tools, model) { let role = msg.role; // Normalize: system/tool -> user + const wasSystem = role === ROLE.SYSTEM; if (role === ROLE.SYSTEM || role === ROLE.TOOL) { role = ROLE.USER; } @@ -322,7 +145,7 @@ function convertMessages(messages, tools, model) { pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: text }] }); }); @@ -334,11 +157,14 @@ function convertMessages(messages, tools, model) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, - status: "success", + status: msg.is_error || msg.status === "error" ? "error" : "success", content: [{ text: toolContent }] }); } else if (content) { - pendingUserContent.push(content); + // tags: Claude models treat these as authoritative directives. + pendingUserContent.push( + wasSystem ? `\n${content}\n` : content + ); } } else if (role === ROLE.ASSISTANT) { // Extract text content and tool uses @@ -405,14 +231,8 @@ function convertMessages(messages, tools, model) { } } - // Grab tools from first history item BEFORE cleanup removes them - const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools; - // Clean up history for Kiro API compatibility history.forEach(item => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if (item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { delete item.userInputMessage.userInputMessageContext; @@ -465,33 +285,6 @@ function convertMessages(messages, tools, model) { }; } - // Reconcile orphaned toolResults across history AND currentMessage — when - // client-side compaction removes assistant messages containing tool_use but - // keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the - // content back into the user text instead of discarding it. Run after - // currentMessage is finalized (an orphan can be merged into it) and before - // tool injection (which may re-add userInputMessageContext). - // - // Only needed on the tools-present path: when the client sent no tools, - // flattenToolInteractions already collapsed every toolResult to text, so - // there is nothing structured left to orphan. - if (clientProvidedTools) { - reconcileOrphanedToolResults(mergedHistory, currentMessage); - } - - // Inject tools into currentMessage AFTER cleanup. Tools only exist here when - // the client explicitly sent them (otherwise flattenToolInteractions already - // collapsed all tool content to text upstream, so there is nothing to carry). - const resolvedTools = firstHistoryTools; - - if (resolvedTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; - } - return { history: mergedHistory, currentMessage }; } @@ -505,12 +298,10 @@ function convertMessages(messages, tools, model) { * Kiro's 2-3 minute server timeout. The suffix is stripped before being * sent upstream. * - * 2. Thinking / reasoning. Kiro does not accept `thinking.type` or - * `reasoning_effort` natively. The only way to enable reasoning is to - * inject `enabled` into the user content - * sent upstream. Detection covers Anthropic-Beta header, Claude API + * 2. Thinking / reasoning. Detection covers Anthropic-Beta header, Claude API * `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model - * name hints. + * name hints. Supported models receive Kiro's schema-specific effort fields; + * legacy prompt tags remain only for models that need them. */ export function openaiToKiroRequest(model, body, stream, credentials) { const messages = body.messages || []; @@ -519,10 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertMessages(messages, upstreamModel); // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is // account-specific. Injecting the shared builder-id/social *default* placeholder @@ -542,47 +338,92 @@ export function openaiToKiroRequest(model, body, stream, credentials) { ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); - let finalContent = currentMessage?.userInputMessage?.content || ""; - const timestamp = new Date().toISOString(); - // Build the system-prompt prefix that goes ABOVE the user message body. - // Order: thinking_mode tag first (so Kiro sees it before any user text), - // then context/timestamp marker, then optional agentic chunked-write prompt. - const prefixParts = []; - if (thinkingBudget !== null) { - prefixParts.push(buildThinkingSystemPrefix(thinkingBudget)); + // Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback + // too because the CodeWhisperer surface does not always enforce top-level + // systemPrompt for direct calls. + const systemPromptParts = []; + if (thinkingBudget !== null && !usesNativeGptEffort) { + systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); } - prefixParts.push(`[Context: Current time is ${timestamp}]`); if (agentic) { - prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); + systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); } - finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`; + const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n"); + const currentTimeContext = `[Context: Current time is ${timestamp}]`; + const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n"); + + const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }); + const conversationId = sessionIdentity.sessionId; + const continuationId = resolveContinuationId({ + sessionId: conversationId, + connectionId: credentials?.connectionId, + scope: "kiro", + ephemeral: sessionIdentity.ephemeral, + }); + const replay = applyKiroSessionReplay({ + conversationId, + connectionId: credentials?.connectionId, + modelId: upstreamModel, + systemPrompt, + contentPrefix, + currentContentPrefix: currentTimeContext, + history, + currentMessage, + }); + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + // canonicalizeKiroConversation() already ran its second-chance repair (flatten + // every structured tool turn to text, then re-validate). A body that is STILL + // invalid here cannot be made shippable, and Kiro answers it with + // 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}. + // Fail locally instead: chatCore turns a falsy return into a 400 without + // spending an upstream call or a per-account cooldown. The taxonomy + // (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending + // turn so the shape can be diagnosed from the log alone. + if (!canonical.valid) { + console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`); + return null; + } + const replayCurrent = canonical.currentMessage.userInputMessage; const payload = { conversationState: { chatTriggerType: "MANUAL", - conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }), + conversationId, + agentContinuationId: continuationId, + agentTaskType: "vibe", currentMessage: { userInputMessage: { - content: finalContent, + content: replayCurrent.content || "", modelId: upstreamModel, origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.images?.length > 0 && { - images: currentMessage.userInputMessage.images + ...(replayCurrent.images?.length > 0 && { + images: replayCurrent.images }), - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext + ...(replayCurrent.userInputMessageContext && { + userInputMessageContext: replayCurrent.userInputMessageContext }) } }, - history: history - } + history: canonical.history + }, + agentMode: "vibe", }; if (profileArn) { payload.profileArn = profileArn; } + if (systemPrompt) payload.systemPrompt = systemPrompt; + if (additionalModelRequestFields) { + payload.additionalModelRequestFields = additionalModelRequestFields; + } if (maxTokens || temperature !== undefined || topP !== undefined) { payload.inferenceConfig = {}; diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 1c9ece5b..455672b1 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -75,6 +75,15 @@ export function kiroToClaudeResponse(chunk, state) { ? data.usage.completion_tokens : 0; state.usage = { input_tokens: promptTokens, output_tokens: outputTokens }; + // Claude clients read cache_read/cache_creation to price a turn and to size + // their prompt cache. Both spellings are accepted because the Kiro executor + // emits the Chat shape and passthrough responses use the nested details form. + const cacheRead = data.usage.cache_read_input_tokens + ?? data.usage.prompt_tokens_details?.cached_tokens; + const cacheCreation = data.usage.cache_creation_input_tokens + ?? data.usage.prompt_tokens_details?.cache_creation_tokens; + if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead; + if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation; } // First chunk → emit message_start. @@ -254,6 +263,13 @@ export function kiroToClaudeNonStreaming(data) { usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0, + // Same cache preservation as the streaming path above. + ...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number" + ? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens } + : {}), + ...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number" + ? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens } + : {}), }, }; } diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index b9336785..ff55bb4e 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } } - // Handle tool_calls - if (delta.tool_calls) { + // Handle tool_calls (empty array is truthy; require a real call) + if (delta.tool_calls && delta.tool_calls.length) { closeMessage(state, emit, idx); for (const tc of delta.tool_calls) { emitToolCall(state, emit, tc); @@ -258,24 +258,43 @@ function closeMessage(state, emit, idx) { } } +function isCustomTool(state, name) { + return !!name && state.customToolNames?.has(name); +} + +function extractCustomToolInput(argumentsText) { + if (typeof argumentsText !== "string") return ""; + try { + const parsed = JSON.parse(argumentsText); + if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input; + } catch { /* incomplete or raw freeform input */ } + return argumentsText; +} + function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; const newCallId = tc.id; const funcName = tc.function?.name; if (funcName) state.funcNames[tcIdx] = funcName; + if (newCallId) state.funcCallIds[tcIdx] = newCallId; + + // Some compatible providers split the call id and function name across + // chunks. Wait for both before deciding whether this is a custom tool; + // otherwise an `exec` call can be irreversibly announced as function_call. + const callId = state.funcCallIds[tcIdx]; + if (!state.funcItemAdded[tcIdx] && callId && state.funcNames[tcIdx]) { + state.funcItemAdded[tcIdx] = true; + const custom = isCustomTool(state, state.funcNames[tcIdx]); - if (!state.funcCallIds[tcIdx] && newCallId) { - state.funcCallIds[tcIdx] = newCallId; - emit("response.output_item.added", { type: "response.output_item.added", output_index: tcIdx, item: { - id: `fc_${newCallId}`, - type: RESPONSES_ITEM.FUNCTION_CALL, - arguments: "", - call_id: newCallId, + id: `${custom ? "ctc" : "fc"}_${callId}`, + type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, + ...(custom ? { input: "" } : { arguments: "" }), + call_id: callId, name: state.funcNames[tcIdx] || "" } }); @@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) { if (tc.function?.arguments) { const refCallId = state.funcCallIds[tcIdx] || newCallId; - if (refCallId) { + if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) { emit("response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${refCallId}`, @@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) { delta: tc.function.arguments }); } + // Custom input is emitted once at close, after the Chat JSON wrapper can be + // parsed and unwrapped. Streaming the raw JSON fragments would expose + // {"input":"..."} instead of the freeform program Codex expects. state.funcArgsBuf[tcIdx] += tc.function.arguments; } } @@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { const args = state.funcArgsBuf[idx] || "{}"; - - emit("response.function_call_arguments.done", { - type: "response.function_call_arguments.done", - item_id: `fc_${callId}`, - output_index: parseInt(idx), - arguments: args - }); + const custom = isCustomTool(state, state.funcNames[idx]); + + if (custom) { + const input = extractCustomToolInput(args); + emit("response.custom_tool_call_input.delta", { + type: "response.custom_tool_call_input.delta", + item_id: `ctc_${callId}`, + output_index: parseInt(idx), + delta: input + }); + emit("response.custom_tool_call_input.done", { + type: "response.custom_tool_call_input.done", + item_id: `ctc_${callId}`, + output_index: parseInt(idx), + input + }); + } else { + emit("response.function_call_arguments.done", { + type: "response.function_call_arguments.done", + item_id: `fc_${callId}`, + output_index: parseInt(idx), + arguments: args + }); + } emit("response.output_item.done", { type: "response.output_item.done", output_index: parseInt(idx), item: { - id: `fc_${callId}`, - type: RESPONSES_ITEM.FUNCTION_CALL, - arguments: args, + id: `${custom ? "ctc" : "fc"}_${callId}`, + type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, + ...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }), call_id: callId, name: state.funcNames[idx] || "" } diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js index 61c25645..958122ce 100644 --- a/open-sse/translator/schema/blocks.js +++ b/open-sse/translator/schema/blocks.js @@ -27,6 +27,9 @@ export const RESPONSES_ITEM = { MESSAGE: "message", FUNCTION_CALL: "function_call", FUNCTION_CALL_OUTPUT: "function_call_output", + CUSTOM_TOOL_CALL: "custom_tool_call", + CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output", + ADDITIONAL_TOOLS: "additional_tools", REASONING: "reasoning", OUTPUT_TEXT: "output_text", INPUT_TEXT: "input_text", diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.js deleted file mode 100644 index 11b2eb81..00000000 --- a/open-sse/utils/claudeHeaderCache.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Singleton cache for real Claude Code client headers. - * Captures headers from authentic Claude Code requests and makes them available - * for forwarding to api.anthropic.com, replacing static hardcoded values. - */ - -const CLAUDE_IDENTITY_HEADERS = [ - "user-agent", - "anthropic-beta", - "anthropic-version", - "anthropic-dangerous-direct-browser-access", - "x-app", - "x-stainless-helper-method", - "x-stainless-retry-count", - "x-stainless-runtime-version", - "x-stainless-package-version", - "x-stainless-runtime", - "x-stainless-lang", - "x-stainless-arch", - "x-stainless-os", - "x-stainless-timeout", - "x-claude-code-session-id", - "package-version", - "runtime-version", - "os", - "arch", -]; - -let cachedHeaders = null; - -/** - * Detect if request headers look like a real Claude Code client. - * @param {object} headers - Lowercase header key/value object - */ -function isClaudeCodeClient(headers) { - const ua = (headers["user-agent"] || "").toLowerCase(); - const xApp = (headers["x-app"] || "").toLowerCase(); - return ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli"; -} - -/** - * Store Claude Code identity headers if this looks like a real client request. - * Called at the entry point before any translation/forwarding. - * @param {object} headers - Lowercase header key/value object (from request.headers.entries()) - */ -export function cacheClaudeHeaders(headers) { - if (!headers || typeof headers !== "object") return; - if (!isClaudeCodeClient(headers)) return; - - const captured = {}; - for (const key of CLAUDE_IDENTITY_HEADERS) { - if (headers[key] !== undefined && headers[key] !== null) { - captured[key] = headers[key]; - } - } - - if (Object.keys(captured).length > 0) { - cachedHeaders = captured; - console.log(`[ClaudeHeaders] Cached ${Object.keys(captured).length} identity headers from Claude Code client`); - } -} - -/** - * Get the most recently cached Claude Code identity headers. - * Returns null if no authentic client request has been seen yet (cold start). - * @returns {object|null} - */ -export function getCachedClaudeHeaders() { - return cachedHeaders; -} diff --git a/open-sse/utils/clientDetector.js b/open-sse/utils/clientDetector.js index 2d1381bc..12020762 100644 --- a/open-sse/utils/clientDetector.js +++ b/open-sse/utils/clientDetector.js @@ -22,6 +22,7 @@ export function detectClientTool(headers = {}, body = {}) { const xApp = (headers["x-app"] || "").toLowerCase(); const openaiIntent = (headers["openai-intent"] || "").toLowerCase(); const initiator = (headers["x-initiator"] || headers["X-Initiator"] || "").toLowerCase(); + const originator = (headers["originator"] || "").toLowerCase(); // Antigravity: detected via body field (not header) if (body.userAgent === "antigravity") return "antigravity"; @@ -37,8 +38,10 @@ export function detectClientTool(headers = {}, body = {}) { // Gemini CLI if (ua.includes("gemini-cli")) return "gemini-cli"; - // Codex CLI - if (ua.includes("codex-cli")) return "codex"; + // Codex CLI/Desktop — codex-tui is the current Rust CLI, codex-cli/codex_cli_rs legacy; + // Codex Desktop identifies via UA "Codex Desktop" or originator "codex_work_desktop" + if (ua.includes("codex-tui") || ua.includes("codex-cli") || ua.includes("codex_cli_rs") || + ua.includes("codex desktop") || originator.startsWith("codex_")) return "codex"; // DeepSeek TUI if (ua.includes("deepseek-tui")) return "deepseek-tui"; diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js index 961df7a1..81bac255 100644 --- a/open-sse/utils/cursorChecksum.js +++ b/open-sse/utils/cursorChecksum.js @@ -128,7 +128,8 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "3.1.0", + "x-cursor-client-version": "3.12.17", + "x-cursor-client-commit": "0fb762053c34788bb7760d5673f8a6d4c8589d50", "x-cursor-client-type": "ide", "x-cursor-client-os": os, "x-cursor-client-arch": arch, diff --git a/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js new file mode 100644 index 00000000..d758eed6 --- /dev/null +++ b/open-sse/utils/kiroSessionReplay.js @@ -0,0 +1,144 @@ +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; + +const sessionStartStore = new Map(); +const MAX_SESSION_STARTS = 5000; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function sessionKey(connectionId, conversationId) { + return `${connectionId || ""}:${conversationId || ""}`; +} + +function ensureUserMessageModelId(message, modelId) { + if (message?.userInputMessage && !message.userInputMessage.modelId && modelId) { + message.userInputMessage.modelId = modelId; + } + return message; +} + +function ensureHistoryModelIds(history, modelId) { + for (const item of history || []) { + ensureUserMessageModelId(item, modelId); + } + return history; +} + +function prefixUserMessage(message, contentPrefix, modelId) { + const out = clone(message) || { userInputMessage: { content: "" } }; + if (!out.userInputMessage) out.userInputMessage = { content: "" }; + ensureUserMessageModelId(out, modelId); + if (contentPrefix) { + const content = out.userInputMessage.content || ""; + out.userInputMessage.content = content + ? `${contentPrefix}\n\n${content}` + : contentPrefix; + } + return out; +} + +function findFirstUserIndex(history) { + return history.findIndex((item) => item?.userInputMessage); +} + +function hasToolResults(message) { + return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length; +} + +function canReplaceSessionStart(history, firstUserIndex) { + return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]); +} + +function rememberSessionStart(key, entry) { + if (sessionStartStore.size >= MAX_SESSION_STARTS) { + sessionStartStore.delete(sessionStartStore.keys().next().value); + } + sessionStartStore.set(key, { ...entry, lastUsed: Date.now() }); +} + +/** + * Preserve Kiro cacheability by freezing the first user message (`msg0`) for a + * session, replaying that exact message as the first history user on later + * turns, and injecting volatile current-time context only into the current turn. + */ +export function applyKiroSessionReplay({ + conversationId, + connectionId, + modelId, + systemPrompt = "", + contentPrefix = "", + currentContentPrefix = "", + history = [], + currentMessage, +} = {}) { + const key = sessionKey(connectionId, conversationId); + const existing = conversationId ? sessionStartStore.get(key) : null; + const baseHistory = clone(history) || []; + const baseCurrent = clone(currentMessage) || { userInputMessage: { content: "" } }; + + if (existing && existing.modelId === modelId && existing.systemPrompt === systemPrompt) { + existing.lastUsed = Date.now(); + const firstUserIndex = findFirstUserIndex(baseHistory); + const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId); + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { + baseHistory[firstUserIndex] = sessionStart; + } else { + baseHistory.unshift(sessionStart); + if (baseHistory.length === 1) { + baseHistory.push({ assistantResponseMessage: { content: "..." } }); + } + } + return { + history: ensureHistoryModelIds(baseHistory, modelId), + currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId), + replayed: true, + }; + } + + const firstUserIndex = findFirstUserIndex(baseHistory); + let sessionStart; + let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId); + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { + sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId); + baseHistory[firstUserIndex] = clone(sessionStart); + nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); + } else if (firstUserIndex >= 0) { + sessionStart = prefixUserMessage( + { userInputMessage: { content: "", modelId } }, + contentPrefix, + modelId + ); + baseHistory.unshift(clone(sessionStart)); + nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); + } else { + sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId); + nextCurrent = clone(sessionStart); + } + + if (conversationId) { + rememberSessionStart(key, { + sessionStart: clone(sessionStart), + modelId, + systemPrompt, + }); + } + + return { + history: ensureHistoryModelIds(baseHistory, modelId), + currentMessage: nextCurrent, + replayed: false, + }; +} + +export function clearKiroSessionReplayStore() { + sessionStartStore.clear(); +} + +const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of sessionStartStore) { + if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) sessionStartStore.delete(key); + } +}, MEMORY_CONFIG.sessionCleanupIntervalMs); +if (cleanup.unref) cleanup.unref(); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index 05f90896..b6f16f1a 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -13,6 +13,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; // Runtime storage: Key = connectionId, Value = { sessionId, lastUsed } const runtimeSessionStore = new Map(); +const continuationStore = new Map(); // Periodically evict entries that haven't been used within TTL const cleanupInterval = setInterval(() => { @@ -80,6 +81,7 @@ export function generateBinaryStyleId() { export function clearSessionStore() { runtimeSessionStore.clear(); assistantSessionStore.clear(); + continuationStore.clear(); } // Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed } @@ -87,9 +89,10 @@ const assistantSessionStore = new Map(); const ASSISTANT_MIN_LEN = 50; const ASSISTANT_CAP_LEN = 50; const MAX_ASSISTANT_SESSIONS = 5000; +const MAX_CONTINUATION_SESSIONS = 5000; // Client headers/body fields that carry an upstream session id (priority order) -const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"]; +const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"]; const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/; function sha16(text) { @@ -131,7 +134,7 @@ function extractAntigravitySession(body) { return m ? normalizeSessionId(m[1]) : null; } -function extractClientSessionId(headers, body) { +function extractClientSessionId(headers, body, scope = "") { const claude = extractClaudeCodeSession(body?.metadata?.user_id); if (claude) return `claude:${claude}`; const antigravity = extractAntigravitySession(body); @@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) { const v = headerValue(headers, key); if (v) return v; } + const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id"); + if (requestId) return requestId; const fromBody = normalizeSessionId(body?.prompt_cache_key) || normalizeSessionId(body?.session_id) || normalizeSessionId(body?.conversation_id) || - normalizeSessionId(body?.metadata?.user_id); + (scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id)); return fromBody || null; } +function requestMessages(body) { + if (Array.isArray(body?.messages)) return body.messages; + if (Array.isArray(body?.input)) return body.input; + return []; +} + // Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited) function accumulateAssistantText(body) { - const items = Array.isArray(body?.input) ? body.input - : Array.isArray(body?.messages) ? body.messages : null; + const items = requestMessages(body); if (!items) return ""; let text = ""; for (const item of items) { @@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) { * @param {string} [opts.connectionId] - Connection identifier (fallback scope) * @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback) * @param {string} [opts.scope] - Provider scope to isolate cache keys across providers - * @returns {string} A stable session id + * @returns {{sessionId: string, ephemeral: boolean}} A session id plus whether it is one-shot */ -export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) { - const client = extractClientSessionId(headers, body); - if (client) return client; - const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body); - if (fromAssistant) return fromAssistant; +export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) { + const client = extractClientSessionId(headers, body, scope); + if (client) return { sessionId: client, ephemeral: false }; + const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body); + if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false }; const ws = normalizeSessionId(workspaceId); - if (ws) return ws; - return deriveSessionId(connectionId); + if (ws) return { sessionId: ws, ephemeral: false }; + if (scope === "kiro") return { sessionId: generateBinaryStyleId(), ephemeral: true }; + return { sessionId: deriveSessionId(connectionId), ephemeral: false }; +} + +export function resolveSessionId(opts = {}) { + return resolveSessionIdentity(opts).sessionId; +} + +export function resolveContinuationId({ sessionId, connectionId, scope = "", ephemeral = false } = {}) { + if (ephemeral) return crypto.randomUUID(); + const key = `${scope}:${connectionId || ""}:${sessionId || ""}`; + const existing = continuationStore.get(key); + if (existing) { + existing.lastUsed = Date.now(); + continuationStore.delete(key); + continuationStore.set(key, existing); + return existing.continuationId; + } + const continuationId = crypto.randomUUID(); + if (continuationStore.size >= MAX_CONTINUATION_SESSIONS) { + continuationStore.delete(continuationStore.keys().next().value); + } + continuationStore.set(key, { continuationId, lastUsed: Date.now() }); + return continuationId; } // Capture session id from request body + credentials (envelope still intact here) @@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => { for (const [key, entry] of assistantSessionStore) { if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key); } + for (const [key, entry] of continuationStore) { + if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) continuationStore.delete(key); + } }, MEMORY_CONFIG.sessionCleanupIntervalMs); if (assistantCleanup.unref) assistantCleanup.unref(); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 54754923..33e7fb04 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -1,12 +1,7 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; -<<<<<<< HEAD -import { extractUsage, hasValidUsage, estimateUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; -import { saveUsageStats } from "../handlers/chatCore/requestDetail.js"; -======= import { extractUsage, mergeUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; ->>>>>>> 7f436e2792be4fa5a4d1c4d6b8e9bc85eaaa6a3d import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; @@ -49,6 +44,7 @@ export function createSSEStream(options = {}) { provider = null, reqLogger = null, toolNameMap = null, + customToolNames = null, model = null, connectionId = null, body = null, @@ -62,7 +58,9 @@ export function createSSEStream(options = {}) { // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks const decoder = new TextDecoder("utf-8", { fatal: false }); - const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null; + const state = mode === STREAM_MODE.TRANSLATE + ? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model } + : null; let totalContentLength = 0; let accumulatedContent = ""; @@ -362,7 +360,7 @@ export function createSSEStream(options = {}) { } if (hasValidUsage(usage)) { - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey }); + logUsage(provider, usage, model, connectionId, apiKey); } else { appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } @@ -451,7 +449,7 @@ export function createSSEStream(options = {}) { } if (hasValidUsage(state?.usage)) { - saveUsageStats({ provider: state.provider || targetFormat, model, tokens: state.usage, connectionId, apiKey }); + logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); } else { appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); } @@ -469,7 +467,7 @@ export function createSSEStream(options = {}) { }); } -export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, targetFormat, @@ -477,6 +475,7 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p provider, reqLogger, toolNameMap, + customToolNames, model, connectionId, body, diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index b8a06e2f..7f04427d 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -15,16 +15,19 @@ function getTimeString() { * @param {string} options.provider - Provider name * @param {string} options.model - Model name */ -export function createStreamController({ onDisconnect, onError, log, provider, model } = {}) { +export function createStreamController({ onDisconnect, onError, log, provider, model, reqTag = "" } = {}) { const abortController = new AbortController(); const startTime = Date.now(); let disconnected = false; let abortTimeout = null; - const logStream = (status) => { + // Only abnormal terminations are logged; normal completion is covered by "📊 done". + // isError uses errorLine (always shown, ignores LOG_LEVEL) so failures survive quiet levels. + const logStream = (symbol, status, isError = false) => { const duration = Date.now() - startTime; - const p = provider?.toUpperCase() || "UNKNOWN"; - console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`); + const emit = isError ? log?.errorLine : log?.line; + if (emit) emit(reqTag, symbol, `${status} · ${provider}/${model} · ${duration}ms`); + else console.log(`[${getTimeString()}] ${symbol} ${provider}/${model} · ${status} · ${duration}ms`); }; return { @@ -38,7 +41,7 @@ export function createStreamController({ onDisconnect, onError, log, provider, m if (disconnected) return; disconnected = true; - logStream(`disconnect: ${reason}`); + logStream("⚡", `DISCONNECT: ${reason}`); dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`); // Delay abort to allow cleanup @@ -49,13 +52,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m onDisconnect?.({ reason, duration: Date.now() - startTime }); }, - // Call when stream completes normally + // Call when stream completes normally (no line here — "📊 done" is authoritative) handleComplete: () => { if (disconnected) return; disconnected = true; - logStream("complete"); - if (abortTimeout) { clearTimeout(abortTimeout); abortTimeout = null; @@ -73,11 +74,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m } if (error.name === "AbortError") { - logStream("aborted"); + logStream("⚡", "ABORTED"); return; } - logStream(`error: ${error.message}`); + logStream("✗", `ERROR: ${error.message}${error.stack ? `\n ${error.stack}` : ""}`, true); onError?.(error); }, diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index ce351139..13d050d0 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -4,6 +4,9 @@ import { FORMATS } from "../translator/formats.js"; +// Legacy per-chunk usage console line; off by default (superseded by "📊 done") +const DEBUG_USAGE = process.env.LOG_USAGE_VERBOSE === "1"; + // ANSI color codes export const COLORS = { reset: "\x1b[0m", @@ -400,6 +403,10 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) { if (!usage || typeof usage !== "object") return; + // Console output moved to the unified "📊 done" line (streamingHandler). Kept as + // a no-op hook so callers stay unchanged; usage persistence happens via saveUsageStats. + if (!DEBUG_USAGE) return; + const p = provider?.toUpperCase() || "UNKNOWN"; // Support both formats: diff --git a/package.json b/package.json index a08b475d..f1c2c97f 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,18 @@ { "name": "9router-app", - "version": "0.5.18", + "version": "0.5.55", "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack --port 20127", + "dev": "next dev --port 20127", + "dev:webpack": "next dev --webpack --port 20127", "build": "next build --webpack", - "start": "next start", + "postbuild": "node scripts/copy-standalone-assets.mjs", + "postbuild:bun": "node scripts/copy-standalone-assets.mjs", + "start": "node custom-server.js --port 20127", "dev:bun": "bun --bun next dev --webpack --port 20127", "build:bun": "bun --bun next build --webpack", - "start:bun": "bun ./.next/standalone/server.js", + "start:bun": "bun ./.next/standalone/custom-server.js", "cli:pack": "npm --prefix cli run pack:cli", "cli:publish": "npm --prefix cli run publish:cli" }, @@ -20,11 +23,12 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@next/third-parties": "^16.2.9", + "@node-saml/node-saml": "^5.1.0", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", + "chalk": "^5.6.2", "confbox": "^0.2.4", "express": "^5.2.1", - "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", @@ -35,6 +39,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", + "prop-types": "^15.8.1", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", diff --git a/public/i18n/literals/ar.json b/public/i18n/literals/ar.json index 32b72f8b..f17abcf2 100644 --- a/public/i18n/literals/ar.json +++ b/public/i18n/literals/ar.json @@ -12,6 +12,7 @@ "Logout": "تسجيل الخروج", "Login": "تسجيل الدخول", "Providers": "الموفرون", + "Style": "النمط", "Usage": "الإحصائيات", "API Key": "مفتاح API", "Connected": "متصل", @@ -176,6 +177,7 @@ "How it works:": "كيف يعمل:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "طلب Antigravity/Copilot IDE → إعادة توجيه DNS إلى localhost:443 → يعترض وكيل MITM → 9Router → الرد إلى Antigravity/Copilot", "No API keys — create one in Keys page": "لا توجد مفاتيح API — قم بإنشاء واحدة في صفحة المفاتيح", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "مثال: صوت دافئ ولطيف، يتحدث ببطء بلهجة بريطانية", "sk_9router (default)": "sk_9router (افتراضي)", "Server started": "تم بدء الخادم", "Failed to start server": "فشل في بدء الخادم", diff --git a/public/i18n/literals/bn.json b/public/i18n/literals/bn.json index ef71ddf8..a8ccc168 100644 --- a/public/i18n/literals/bn.json +++ b/public/i18n/literals/bn.json @@ -12,6 +12,7 @@ "Logout": "লগ আউট", "Login": "লগ ইন", "Providers": "সরবরাহকারী", + "Style": "শৈলী", "Usage": "ব্যবহারের পরিসংখ্যান", "API Key": "API কী", "Connected": "সংযুক্ত", @@ -176,6 +177,7 @@ "How it works:": "এটি কীভাবে কাজ করে:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE অনুরোধ → DNS কে localhost:443 তে রিডিরেক্ট করুন → MITM প্রক্সি ইন্টারসেপ্ট করে → 9Router → Antigravity/Copilot এ প্রতিক্রিয়া", "No API keys — create one in Keys page": "কোন API কী নেই — Keys পৃষ্ঠায় একটি তৈরি করুন", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "যেমন: উষ্ণ, মৃদু কণ্ঠস্বর, ব্রিটিশ উচ্চারণে ধীরে ধীরে কথা বলা", "sk_9router (default)": "sk_9router (ডিফল্ট)", "Server started": "সার্ভার শুরু হয়েছে", "Failed to start server": "সার্ভার শুরু করতে ব্যর্থ", diff --git a/public/i18n/literals/cs.json b/public/i18n/literals/cs.json index ed0d991a..d73139cd 100644 --- a/public/i18n/literals/cs.json +++ b/public/i18n/literals/cs.json @@ -12,6 +12,7 @@ "Logout": "Odhlásit se", "Login": "Přihlásit se", "Providers": "Poskytovatelé", + "Style": "Styl", "Usage": "Statistika", "API Key": "Klíč API", "Connected": "Připojeno", @@ -176,6 +177,7 @@ "How it works:": "Jak to funguje:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Požadavek Antigravity/Copilot IDE → Přesměrování DNS na localhost:443 → Proxy MITM zachycuje → 9Router → odpověď na Antigravity/Copilot", "No API keys — create one in Keys page": "Žádné klíče API — vytvořte jeden na stránce Klíče", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "např.: teplý, jemný hlas, mluvící pomalu s britským přízvukem", "sk_9router (default)": "sk_9router (výchozí)", "Server started": "Server spuštěn", "Failed to start server": "Spuštění serveru se nezdařilo", diff --git a/public/i18n/literals/da.json b/public/i18n/literals/da.json index c81bbe79..aca9c53c 100644 --- a/public/i18n/literals/da.json +++ b/public/i18n/literals/da.json @@ -12,6 +12,7 @@ "Logout": "Log ud", "Login": "Log ind", "Providers": "Udbydere", + "Style": "Stil", "Usage": "Forbrugsstatistik", "API Key": "API-nøgle", "Connected": "Forbundet", @@ -176,6 +177,7 @@ "How it works:": "Sådan virker det:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-anmodning → DNS-omdirigering til localhost:443 → MITM-proxy aflytter → 9Router → svar til Antigravity/Copilot", "No API keys — create one in Keys page": "Ingen API-nøgler — opret en på Keys-siden", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "fx.: en varm, blød stemme, der taler langsomt med britisk accent", "sk_9router (default)": "sk_9router (standard)", "Server started": "Server startet", "Failed to start server": "Fejl ved start af server", diff --git a/public/i18n/literals/de.json b/public/i18n/literals/de.json index 57bf3ef3..1a02fab7 100644 --- a/public/i18n/literals/de.json +++ b/public/i18n/literals/de.json @@ -12,6 +12,7 @@ "Logout": "Abmelden", "Login": "Anmelden", "Providers": "Anbieter", + "Style": "Stil", "Usage": "Statistiken", "API Key": "API-Schlüssel", "Connected": "Verbunden", @@ -176,6 +177,7 @@ "How it works:": "So funktioniert es:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-Anforderung → DNS-Umleitung auf localhost:443 → MITM-Proxy abfangen → 9Router → Antwort auf Antigravity/Copilot", "No API keys — create one in Keys page": "Keine API-Schlüssel — erstellen Sie einen auf der Seite Schlüssel", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "z. B.: eine warme, sanfte Stimme, die langsam mit britischem Akzent spricht", "sk_9router (default)": "sk_9router (Standard)", "Server started": "Server gestartet", "Failed to start server": "Server konnte nicht gestartet werden", diff --git a/public/i18n/literals/el.json b/public/i18n/literals/el.json index bfbf8888..a220ac81 100644 --- a/public/i18n/literals/el.json +++ b/public/i18n/literals/el.json @@ -12,6 +12,7 @@ "Logout": "Έξοδος", "Login": "Σύνδεση", "Providers": "Παρόχοι", + "Style": "Στυλ", "Usage": "Στατιστικά χρήσης", "API Key": "Κλειδί API", "Connected": "Συνδεδεμένο", @@ -176,6 +177,7 @@ "How it works:": "Πώς λειτουργεί:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Αίτημα Antigravity/Copilot IDE → Ανακατεύθυνση DNS στο localhost:443 → Ο διακομιστής μεσολάβησης MITM παρεμβαίνει → 9Router → απάντηση στο Antigravity/Copilot", "No API keys — create one in Keys page": "Δεν υπάρχουν κλειδιά API — δημιουργήστε ένα στη σελίδα Keys", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "π.χ.: μια ζεστή, απαλή φωνή, που μιλάει αργά με βρετανική προφορά", "sk_9router (default)": "sk_9router (προεπιλεγμένο)", "Server started": "Ο διακομιστής ξεκίνησε", "Failed to start server": "Αποτυχία εκκίνησης διακομιστή", diff --git a/public/i18n/literals/es.json b/public/i18n/literals/es.json index 69d71e8f..a5e10674 100644 --- a/public/i18n/literals/es.json +++ b/public/i18n/literals/es.json @@ -12,6 +12,7 @@ "Logout": "Cerrar sesión", "Login": "Iniciar sesión", "Providers": "Proveedores", + "Style": "Estilo", "Usage": "Estadísticas", "API Key": "Clave API", "Connected": "Conectado", @@ -176,6 +177,7 @@ "How it works:": "Cómo funciona:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitud de Antigravity/Copilot IDE → Redireccionamiento DNS a localhost:443 → El proxy MITM intercepta → 9Router → respuesta a Antigravity/Copilot", "No API keys — create one in Keys page": "Sin claves API — cree una en la página Claves", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "p. ej.: voz cálida y suave, hablando lentamente con acento británico", "sk_9router (default)": "sk_9router (predeterminado)", "Server started": "Servidor iniciado", "Failed to start server": "Error al iniciar el servidor", diff --git a/public/i18n/literals/fa.json b/public/i18n/literals/fa.json new file mode 100644 index 00000000..0f28367f --- /dev/null +++ b/public/i18n/literals/fa.json @@ -0,0 +1,1393 @@ +{ + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/۱ میلیون توکن). مثال: نرخ ورودی ۲.۵۰ به معنای ۲.۵۰ دلار به ازای هر ۱٬۰۰۰٬۰۰۰ توکن ورودی است.", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/۱ میلیون توکن). مثال: نرخ ورودی ۲.۵۰ به معنای ۲.۵۰ دلار به ازای هر ۱٬۰۰۰٬۰۰۰ توکن ورودی است.", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(از طریق آزمون استنتاج)", + "+ Browse": "+ مرور", + "+ Combo": "+ ترکیب", + "+ Custom": "+ سفارشی", + "+ Save current as...": "+ ذخیره فعلی به عنوان...", + "-compatible models manually or import them from the /models endpoint.": "مدل‌های سازگار را به صورت دستی وارد کنید یا از نقطه پایانی /models وارد کنید.", + ". Click \"Apply\" to auto-configure.": ". برای پیکربندی خودکار روی «اعمال» کلیک کنید.", + "1. CLI & SDKs": "۱. CLI و SDK", + "1. Client Request (Input)": "۱. درخواست مشتری (ورودی)", + "1. Generates SSL cert & adds to system keychain": "۱. گواهی SSL تولید می‌کند و به زنجیره کلید سیستم اضافه می‌کند", + "2. 9Router Hub": "۲. مرکز 9Router", + "2. Provider Request (Translated)": "۲. درخواست ارائه‌دهنده (ترجمه شده)", + "2. Redirects": "۲. تغییر مسیرها", + "24h": "۲۴ ساعت", + "3. AI Providers": "۳. ارائه‌دهندگان هوش مصنوعی", + "3. Maps Antigravity models to any provider via 9Router": "۳. مدل‌های Antigravity را از طریق 9Router به هر ارائه‌دهنده‌ای نگاشت می‌کند", + "3. Provider Response (Raw)": "۳. پاسخ ارائه‌دهنده (خام)", + "30D": "۳۰ روز", + "4. Client Response (Final)": "۴. پاسخ مشتری (نهایی)", + "60D": "۶۰ روز", + "7D": "۷ روز", + "9Router (Entry)": "9Router (ورودی)", + "9Router Base URL": "آدرس پایه 9Router", + ": Account | Workers Scripts | Edit": ": حساب | اسکریپت‌های Workers | ویرایش", + ": Include | Account |": ": شامل | حساب |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "پروکسی نقطه پایانی هوش مصنوعی با داشبورد وب - یک پورت جاوااسکریپت از CLIProxyAPI. به‌طور یکپارچه با Claude Code، OpenAI Codex، Cline، RooCode و سایر ابزارهای CLI کار می‌کند.", + "API Endpoint": "نقطه پایانی API", + "API Key": "کلید API", + "API Key (for Check)": "کلید API (برای بررسی)", + "API Key Compatible Providers": "ارائه‌دهندگان سازگار با کلید API", + "API Key Created": "کلید API ایجاد شد", + "API Key Name": "نام کلید API", + "API Key Providers": "ارائه‌دهندگان کلید API", + "API Keys": "کلیدهای API", + "API Reference": "مرجع API", + "API Token": "توکن API", + "API Tokens": "توکن‌های API", + "API Type": "نوع API", + "API Version": "نسخه API", + "API endpoint configuration": "پیکربندی نقطه پایانی API", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "مرکز هویت AWS IAM", + "AWS Region": "منطقه AWS", + "AWS region for the key (default: us-east-1)": "منطقه AWS برای کلید (پیش‌فرض: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "منطقه AWS برای مرکز هویت شما (پیش‌فرض: us-east-1)", + "About": "درباره", + "Access Anywhere": "دسترسی از هر جا", + "Access Token": "توکن دسترسی", + "Access token will be auto-filled...": "توکن دسترسی به‌طور خودکار پر می‌شود...", + "Access your terminal, desktop & files from anywhere": "از هر جایی به ترمینال، دسکتاپ و فایل‌های خود دسترسی داشته باشید", + "Account": "حساب", + "Account ID": "شناسه حساب", + "Account Resources": "منابع حساب", + "Accounts per page": "تعداد حساب در هر صفحه", + "Action": "عملیات", + "Activate": "فعال‌سازی", + "Active": "فعال", + "Active All": "فعال‌سازی همه", + "Active:": "فعال:", + "Add": "افزودن", + "Add API Key": "افزودن کلید API", + "Add Anthropic Compatible": "افزودن سازگار با Anthropic", + "Add Connection": "افزودن اتصال", + "Add Custom Embedding": "افزودن تعبیه سفارشی", + "Add Custom MCP": "افزودن MCP سفارشی", + "Add Custom Model": "افزودن مدل سفارشی", + "Add Model": "افزودن مدل", + "Add Model Config": "افزودن پیکربندی مدل", + "Add Model for GitHub Copilot": "افزودن مدل برای GitHub Copilot", + "Add Model for OpenCode": "افزودن مدل برای OpenCode", + "Add Model to Combo": "افزودن مدل به ترکیب", + "Add New Provider": "افزودن ارائه‌دهنده جدید", + "Add OpenAI Compatible": "افزودن سازگار با OpenAI", + "Add Provider": "افزودن ارائه‌دهنده", + "Add Proxy Pool": "افزودن استخر پروکسی", + "Add Shorthands": "افزودن میان‌نویس‌ها", + "Add a connection to enable importing models.": "برای فعال‌سازی وارد کردن مدل‌ها، یک اتصال اضافه کنید.", + "Add connection using browser cookie": "افزودن اتصال با استفاده از کوکی مرورگر", + "Add model": "افزودن مدل", + "Add server": "افزودن سرور", + "Add the following configuration to your models array:": "پیکربندی زیر را به آرایه مدل‌های خود اضافه کنید:", + "Add your first connection to get started": "اولین اتصال خود را برای شروع اضافه کنید", + "Administrator required": "نیاز به مدیر سیستم", + "Administrator required — restart 9Router as Administrator to use MITM": "نیاز به مدیر سیستم — برای استفاده از MITM، 9Router را به عنوان مدیر راه‌اندازی مجدد کنید", + "After authorization, copy the full URL from your browser address bar.": "پس از مجوز، URL کامل را از نوار آدرس مرورگر خود کپی کنید.", + "After authorization, copy the full URL from your browser.": "پس از مجوز، URL کامل را از مرورگر خود کپی کنید.", + "After installation, run": "پس از نصب، اجرا کنید", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "پس از ورود، باید URL پاسخ بازگشت را از مرورگر خود کپی کرده و در اینجا بچسبانید.", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "علی‌بابا Qwen Code CLI — از ارائه‌دهندگان OpenAI، Anthropic و Gemini از طریق 9Router پشتیبانی می‌کند", + "All": "همه", + "All AI Providers": "همه ارائه‌دهندگان هوش مصنوعی", + "All Providers": "همه ارائه‌دهندگان", + "All models are responding normally.": "همه مدل‌ها به‌طور عادی پاسخ می‌دهند.", + "All providers": "همه ارائه‌دهندگان", + "All rates are in": "همه نرخ‌ها بر حسب", + "All selected currently unbound": "همه موارد انتخاب شده در حال حاضر بدون اتصال هستند", + "Allow dashboard access via tunnel": "اجازه دسترسی به داشبورد از طریق تونل", + "Allow either password or OIDC.": "اجازه ورود با رمز عبور یا OIDC را بدهید.", + "An error occurred": "خطایی رخ داد", + "An error occurred. Please try again.": "خطایی رخ داد. لطفاً دوباره تلاش کنید.", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "سازگار با Anthropic (تولید)", + "Anthropic Compatible Details": "جزئیات سازگاری با Anthropic", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "درخواست Antigravity/Copilot IDE → تغییر مسیر DNS به localhost:443 → رهگیری پروکسی MITM → 9Router → پاسخ به Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "هر مدلی که در 9Router موجود است قابل استفاده است — نه فقط مدل‌های Qwen. از بین Qwen، Claude، Gemini، GPT و بیشتر انتخاب کنید.", + "App Name": "نام برنامه", + "Apply": "اعمال", + "Apply Proxy": "اعمال پروکسی", + "Applying...": "در حال اعمال...", + "Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟", + "Are you sure you want to disable the tunnel?": "آیا مطمئن هستید که می‌خواهید تونل را غیرفعال کنید؟", + "Attempting to reconnect...": "در حال تلاش برای اتصال مجدد...", + "Audio File": "فایل صوتی", + "Auth Mode": "حالت احراز هویت", + "Authenticate": "احراز هویت", + "Authentication Method": "روش احراز هویت", + "Authentication Successful": "احراز هویت موفق", + "Authentication Successful!": "احراز هویت موفق!", + "Authless": "بدون احراز هویت", + "Authorization Successful!": "مجوز با موفقیت انجام شد!", + "Authorize": "مجوز", + "Auto (by priority)": "خودکار (بر اساس اولویت)", + "Auto Refresh (3s)": "تازه‌سازی خودکار (۳ ثانیه)", + "Auto-detect": "تشخیص خودکار", + "Auto-detecting token...": "در حال تشخیص خودکار توکن...", + "Auto-detecting tokens...": "در حال تشخیص خودکار توکن‌ها...", + "Auto-ping": "پینگ خودکار", + "Auto-refresh": "تازه‌سازی خودکار", + "Auto:": "خودکار:", + "Automatically switch between providers when limits are hit.": "هنگام رسیدن به محدودیت‌ها به‌طور خودکار بین ارائه‌دهندگان جابجا شوید.", + "Available": "موجود", + "Available Models": "مدل‌های موجود", + "Azure Endpoint": "نقطه پایانی Azure", + "Azure OpenAI Configuration": "پیکربندی Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "بازگشت", + "Back to CLI Tools": "بازگشت به ابزارهای CLI", + "Back to Providers": "بازگشت به ارائه‌دهندگان", + "Base URL": "آدرس پایه", + "Batch Import": "وارد کردن دسته‌ای", + "Batch Import Proxies": "وارد کردن دسته‌ای پروکسی‌ها", + "Batch Size": "اندازه دسته", + "Beautiful web dashboard for managing providers and monitoring usage.": "داشبورد وب زیبا برای مدیریت ارائه‌دهندگان و نظارت بر مصرف.", + "Best quality, but costs the most": "بهترین کیفیت، اما هزینه‌برترین", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "مدل را به سمت کد حداقلی سوق دهید: YAGNI، استفاده مجدد از کتابخانه استاندارد، حذف به جای افزودن", + "Binary File": "فایل باینری", + "Blog": "وبلاگ", + "Both": "هر دو", + "Browse & edit files": "مرور و ویرایش فایل‌ها", + "Browse MCP Marketplace": "مرور بازار MCP", + "Browse source, README, and examples.": "مرور کد منبع، README و مثال‌ها.", + "Browser Control (Browser MCP)": "کنترل مرورگر (Browser MCP)", + "Bulk Add": "افزودن عمده", + "CLI Support": "پشتیبانی CLI", + "CLI Tools": "ابزارهای CLI", + "CLI on the host →": "CLI روی میزبان →", + "CLIProxyAPI Auth JSON": "احراز هویت CLIProxyAPI JSON", + "Cache Creation": "ایجاد حافظه پنهان", + "Cache Creation:": "ایجاد حافظه پنهان:", + "Cached": "ذخیره شده در حافظه پنهان", + "Cached Tokens": "توکن‌های ذخیره شده در حافظه پنهان", + "Cached Tokens:": "توکن‌های ذخیره شده در حافظه پنهان:", + "Cached input tokens (typically 50% of input rate)": "توکن‌های ورودی ذخیره شده در حافظه پنهان (معمولاً ۵۰٪ نرخ ورودی)", + "Cached:": "ذخیره شده در حافظه پنهان:", + "Calls per account before switching": "تعداد تماس به ازای هر حساب قبل از تغییر", + "Calls per combo model before switching": "تعداد تماس به ازای هر مدل ترکیبی قبل از تغییر", + "Cancel": "لغو", + "Capacity auto-switch": "تغییر خودکار ظرفیت", + "Cert": "گواهی", + "Change Log": "تاریخچه تغییرات", + "Changelog": "تاریخچه تغییرات", + "Chat": "گفتگو", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "گفتگو / تولید کد از طریق فرمت OpenAI یا Anthropic با پخش جریانی.", + "Chat Completions": "تکمیل گفتگو", + "Check": "بررسی", + "Checking Claude CLI...": "در حال بررسی Claude CLI...", + "Checking Claude Cowork...": "در حال بررسی Claude Cowork...", + "Checking Cline...": "در حال بررسی Cline...", + "Checking Codex CLI...": "در حال بررسی Codex CLI...", + "Checking Copilot config...": "در حال بررسی پیکربندی Copilot...", + "Checking DeepSeek TUI...": "در حال بررسی DeepSeek TUI...", + "Checking Factory Droid CLI...": "در حال بررسی Factory Droid CLI...", + "Checking Hermes Agent...": "در حال بررسی Hermes Agent...", + "Checking Kilo Code...": "در حال بررسی Kilo Code...", + "Checking Open Claw CLI...": "در حال بررسی Open Claw CLI...", + "Checking OpenCode CLI...": "در حال بررسی OpenCode CLI...", + "Checking jcode CLI...": "در حال بررسی jcode CLI...", + "Checking...": "در حال بررسی...", + "Choose API Provider → Ollama": "ارائه‌دهنده API را انتخاب کنید → Ollama", + "Choose how to authenticate with GitLab Duo:": "نحوه احراز هویت با GitLab Duo را انتخاب کنید:", + "Choose your authentication method:": "روش احراز هویت خود را انتخاب کنید:", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - پیکربندی دستی", + "Claude CLI not detected locally": "Claude CLI در سیستم محلی شناسایی نشد", + "Claude CLI not installed": "Claude CLI نصب نشده است", + "Claude Cowork - Manual Configuration": "Claude Cowork - پیکربندی دستی", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (حالت Cowork) شناسایی نشد", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork (استنتاج شخص ثالث)", + "Clear": "پاک کردن", + "Clear (will use main model)": "پاک کردن (از مدل اصلی استفاده خواهد شد)", + "Clear Filters": "پاک کردن فیلترها", + "Clear search": "پاک کردن جستجو", + "Click": "کلیک", + "Click \"View All Model\" → \"Add Custom Model\"": "روی «مشاهده همه مدل‌ها» → «افزودن مدل سفارشی» کلیک کنید", + "Click a model to set/clear active": "برای تنظیم/لغو فعال بودن، روی یک مدل کلیک کنید", + "Click to add, click again to remove. Changes are saved automatically.": "برای افزودن کلیک کنید، برای حذف دوباره کلیک کنید. تغییرات به‌طور خودکار ذخیره می‌شوند.", + "Click to edit": "برای ویرایش کلیک کنید", + "Click to retry": "برای تلاش مجدد کلیک کنید", + "Client ID": "شناسه مشتری", + "Client Request": "درخواست مشتری", + "Client Response": "پاسخ مشتری", + "Client Secret": "راز مشتری", + "Cline - Manual Configuration": "Cline - پیکربندی دستی", + "Cline AI Coding Assistant": "دستیار کدنویسی هوش مصنوعی Cline", + "Cline not detected locally": "Cline در سیستم محلی شناسایی نشد", + "Close": "بستن", + "Close Proxy": "بستن پروکسی", + "Close provider filter": "بستن فیلتر ارائه‌دهنده", + "Close reset credit expiry modal": "بستن پنجره انقضای اعتبار بازنشانی", + "Close test results": "بستن نتایج آزمایش", + "Closing in": "در حال بسته شدن در", + "Cloud Sync": "همگام‌سازی ابری", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "تونل Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - پیکربندی دستی", + "Codex CLI not detected locally": "Codex CLI در سیستم محلی شناسایی نشد", + "Codex CLI not installed": "Codex CLI نصب نشده است", + "Codex Reset Credit Expiry": "انقضای اعتبار بازنشانی Codex", + "Codex uses": "Codex استفاده می‌کند", + "Combo Name": "نام ترکیب", + "Combo Round Robin": "چرخشی ترکیب", + "Combo Sticky Limit": "محدودیت چسبندگی ترکیب", + "Combos": "ترکیبات", + "Coming soon...": "به زودی...", + "Comma-separated hostnames/domains to bypass the proxy.": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی.", + "Comma-separated hosts/domains to bypass proxy": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی", + "Company": "شرکت", + "Complete the authorization in the popup window.": "مجوز را در پنجره بازشو تکمیل کنید.", + "Completion/response tokens": "توکن‌های تکمیل/پاسخ", + "Compress LLM output": "فشرده‌سازی خروجی LLM", + "Compress context": "فشرده‌سازی زمینه", + "Compress prompts via /v1/compress before routing to the model": "فشرده‌سازی پرامپت‌ها از طریق /v1/compress قبل از مسیردهی به مدل", + "Compress tool output": "فشرده‌سازی خروجی ابزار", + "Compress tool output to reduce token usage.": "خروجی ابزار را برای کاهش مصرف توکن فشرده کنید.", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "مسیر پیکربندی: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "مسیر پیکربندی: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "پیکربندی", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "9router را به عنوان یک ارائه‌دهنده سازگار با OpenAI پیکربندی کنید تا تمام درخواست‌های jcode را از طریق لایه بهینه‌سازی 9router مسیردهی کند.", + "Configure CLI tools": "پیکربندی ابزارهای CLI", + "Configure a new AI provider to use with your applications.": "یک ارائه‌دهنده هوش مصنوعی جدید برای استفاده با برنامه‌های خود پیکربندی کنید.", + "Configure pricing rates for cost tracking and calculations": "نرخ‌های قیمت‌گذاری را برای پیگیری و محاسبه هزینه پیکربندی کنید", + "Configure providers and API keys via web interface": "پیکربندی ارائه‌دهندگان و کلیدهای API از طریق رابط وب", + "Configured": "پیکربندی شده", + "Confirm": "تأیید", + "Confirm New Password": "تأیید رمز عبور جدید", + "Confirm Password": "تأیید رمز عبور", + "Confirm new password": "تأیید رمز عبور جدید", + "Connect": "اتصال", + "Connect AI tools remotely": "اتصال ابزارهای هوش مصنوعی از راه دور", + "Connect Cursor IDE": "اتصال Cursor IDE", + "Connect GitLab Duo": "اتصال GitLab Duo", + "Connect Kiro": "اتصال Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "با استفاده از OAuth به ارائه‌دهندگان متصل شوید تا محدودیت‌ها و مصرف سهمیه API خود را پیگیری کنید.", + "Connect via OAuth or API keys. Securely manage credentials.": "از طریق OAuth یا کلیدهای API متصل شوید. اعتبارنامه‌ها را به‌طور امن مدیریت کنید.", + "Connect with OAuth2": "اتصال با OAuth2", + "Connect your account using OAuth2 authentication.": "حساب خود را با استفاده از احراز هویت OAuth2 متصل کنید.", + "Connected": "متصل", + "Connected Successfully!": "اتصال با موفقیت انجام شد!", + "Connected providers only": "فقط ارائه‌دهندگان متصل", + "Connecting...": "در حال اتصال...", + "Connection": "اتصال", + "Connection Details": "جزئیات اتصال", + "Connection Failed": "اتصال ناموفق", + "Connections": "اتصالات", + "Console Log": "لاگ کنسول", + "Contact": "تماس", + "Content": "محتوای", + "Continue": "ادامه", + "Continue AI Assistant": "دستیار هوش مصنوعی Continue", + "Continue to summary": "ادامه به خلاصه", + "Continue with GitHub": "ادامه با GitHub", + "Continue with Google": "ادامه با Google", + "Cookie": "کوکی", + "Cookie Auth": "احراز هویت کوکی", + "Cookie String": "رشته کوکی", + "Cooldown": "آرامش", + "Copied!": "کپی شد!", + "Copy": "کپی", + "Copy & Shutdown": "کپی و خاموش کردن", + "Copy This URL": "کپی این URL", + "Copy a link and paste to your AI to use 9Router — no install needed": "یک لینک کپی کرده و به هوش مصنوعی خود بچسبانید تا از 9Router استفاده کنید — نیازی به نصب نیست", + "Copy combo name": "کپی نام ترکیب", + "Copy install command": "کپی دستور نصب", + "Copy model": "کپی مدل", + "Copy the JSON below to your ~/.qwen/settings.json file.": "JSON زیر را در فایل ~/.qwen/settings.json خود کپی کنید.", + "Copy the entire cookie string (must include BXAuth)": "کل رشته کوکی را کپی کنید (باید شامل BXAuth باشد)", + "Cost": "هزینه", + "Cost Calculation:": "محاسبه هزینه:", + "Costs": "هزینه‌ها", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "هزینه‌ها بر اساس مصرف توکن و نرخ‌های قیمت‌گذاری محاسبه می‌شوند. هزینه هر درخواست با فرمول زیر تعیین می‌شود: (توکن‌های ورودی × نرخ ورودی) + (توکن‌های خروجی × نرخ خروجی) + (توکن‌های ذخیره شده × نرخ ذخیره شده)", + "Could not read Cursor database automatically.": "امکان خواندن خودکار پایگاه داده Cursor وجود ندارد.", + "Create": "ایجاد", + "Create API Key": "ایجاد کلید API", + "Create Combo": "ایجاد ترکیب", + "Create Cowork Combo": "ایجاد ترکیب Cowork", + "Create Key": "ایجاد کلید", + "Create Provider": "ایجاد ارائه‌دهنده", + "Create Token": "ایجاد توکن", + "Create a": "ایجاد یک", + "Create a proxy pool entry, then assign it to connections.": "یک ورودی استخر پروکسی ایجاد کنید، سپس آن را به اتصالات اختصاص دهید.", + "Create model combos with fallback support": "ایجاد ترکیبات مدل با پشتیبانی از پشتیبان جایگزین", + "Create your first API key to get started": "اولین کلید API خود را برای شروع ایجاد کنید", + "Created": "ایجاد شد", + "Creating...": "در حال ایجاد...", + "Current": "فعلی", + "Current Password": "رمز عبور فعلی", + "Current Pricing Overview": "بررسی قیمت‌گذاری فعلی", + "Current password": "رمز عبور فعلی", + "Current: Keeps": "فعلی: نگهداری می‌کند", + "Currently using accounts in priority order (Fill First).": "در حال حاضر از حساب‌ها به ترتیب اولویت استفاده می‌کند (ابتدا پر کردن).", + "Cursor AI Code Editor": "ویرایشگر کد هوش مصنوعی Cursor", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE شناسایی نشد. لطفاً توکن‌های خود را به صورت دستی بچسبانید.", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor درخواست‌ها را از طریق سرور خود مسیردهی می‌کند، بنابراین نقطه پایانی محلی پشتیبانی نمی‌شود. لطفاً تونل یا نقطه پایانی ابری را در تنظیمات فعال کنید.", + "Custom": "سفارشی", + "Custom Pricing:": "قیمت‌گذاری سفارشی:", + "Custom Providers (OpenAI/Anthropic Compatible)": "ارائه‌دهندگان سفارشی (سازگار با OpenAI/Anthropic)", + "Custom Token": "توکن سفارشی", + "Custom accounts per page": "تعداد حساب سفارشی در هر صفحه", + "Custom providers": "ارائه‌دهندگان سفارشی", + "Custom...": "سفارشی...", + "Cycle through accounts to distribute load": "چرخش بین حساب‌ها برای توزیع بار", + "Cycle through providers in combos instead of always starting with first": "چرخش بین ارائه‌دهندگان در ترکیبات به جای همیشه شروع با اولین", + "DNS off": "DNS خاموش", + "Dashboard": "داشبورد", + "Dashboard Password": "رمز عبور داشبورد", + "Dashboard:": "داشبورد:", + "Data Location:": "مکان داده:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "داده‌ها به‌طور یکپارچه از برنامه شما از طریق لایه مسیریابی هوشمند ما به بهترین ارائه‌دهنده برای کار جریان می‌یابد.", + "Data flows seamlessly through our intelligent routing system": "داده‌ها به‌طور یکپارچه از طریق سیستم مسیریابی هوشمند ما جریان می‌یابند", + "Database Location": "مکان پایگاه داده", + "Database backup downloaded": "پشتیبان پایگاه داده دانلود شد", + "Database imported successfully": "پایگاه داده با موفقیت وارد شد", + "DateTime": "تاریخ و زمان", + "Deactivate": "غیرفعال‌سازی", + "Debug": "اشکال‌زدایی", + "Debug translation flow between formats": "اشکال‌زدایی جریان ترجمه بین فرمت‌ها", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - پیکربندی دستی", + "DeepSeek TUI not detected locally": "DeepSeek TUI در سیستم محلی شناسایی نشد", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI از ~/.deepseek/config.toml برای پیکربندی استفاده می‌کند. 9Router ارائه‌دهنده را به حالت 'openai' با base_url، api_key و model شما به‌روز می‌کند.", + "DeepSeek Terminal Coding Agent (Rust TUI)": "عامل کدنویسی ترمینال DeepSeek (Rust TUI)", + "Default Model": "مدل پیش‌فرض", + "Default password is": "رمز عبور پیش‌فرض است", + "Default password is 123456": "رمز عبور پیش‌فرض ۱۲۳۴۵۶ است", + "Delete": "حذف", + "Delete API Key": "حذف کلید API", + "Delete connection": "حذف اتصال", + "Delete saved endpoint": "حذف نقطه پایانی ذخیره شده", + "Delete selected preset": "حذف تنظیم از پیش انتخاب شده", + "Delete this combo?": "این ترکیب حذف شود؟", + "Delete this connection?": "این اتصال حذف شود؟", + "Deno Deploy API Token": "توکن Deno Deploy API", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 بر روی شبکه لبه جهانی با کارایی بالا اجرا می‌شود", + "Deno Relay": "Deno Relay", + "Deploy": "استقرار", + "Deploy Cloudflare Relay": "استقرار Cloudflare Relay", + "Deploy Deno Relay": "استقرار Deno Relay", + "Deploy Relay": "استقرار Relay", + "Deploy Vercel Relay": "استقرار Vercel Relay", + "Deploy multiple relays for maximum IP diversity": "استقرار چندین Relay برای حداکثر تنوع IP", + "Deploy multiple relays on different accounts for more IP diversity": "استقرار چندین Relay در حساب‌های مختلف برای تنوع بیشتر IP", + "Deploying... (may take ~1 min)": "در حال استقرار... (ممکن است حدود ۱ دقیقه طول بکشد)", + "Deployment Name": "نام استقرار", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "یک Cloudflare Worker را به عنوان Relay پروکسی استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق شبکه لبه جهانی Cloudflare ارسال می‌شوند.", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "یک Relay Worker را به شبکه لبه جهانی Deno Deploy استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق لبه Deno ارسال می‌شوند و IP واقعی شما را پنهان می‌کنند.", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "یک تابع Relay لبه را در Vercel استقرار می‌دهد که درخواست‌ها را از طریق شبکه Vercel پروکسی می‌کند.", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "یک تابع Relay لبه را در Vercel استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق شبکه لبه Vercel ارسال می‌شوند و IP واقعی شما را از ارائه‌دهندگان پنهان می‌کنند.", + "Desktop": "دسکتاپ", + "Detail": "جزئیات", + "Details": "جزئیات", + "Dimensions": "ابعاد", + "Disable": "غیرفعال‌سازی", + "Disable All": "غیرفعال‌سازی همه", + "Disable Tailscale": "غیرفعال‌سازی Tailscale", + "Disable Tunnel": "غیرفعال‌سازی تونل", + "Disable connections with depleted quota on the current page": "غیرفعال‌سازی اتصالات با سهمیه تمام شده در صفحه فعلی", + "Disable provider": "غیرفعال‌سازی ارائه‌دهنده", + "Disable this model": "غیرفعال‌سازی این مدل", + "Disabled": "غیرفعال", + "Disabling...": "در حال غیرفعال‌سازی...", + "Disconnected from server": "قطع شده از سرور", + "Dismiss notification": "رد اعلان", + "Display Name": "نام نمایشی", + "Display language": "زبان نمایش", + "Docs": "مستندات", + "Documentation": "مستندات", + "Domain:": "دامنه:", + "Donate": "کمک مالی", + "Done": "انجام شد", + "Download": "دانلود", + "Download Backup": "دانلود پشتیبان", + "Drag to reorder": "برای مرتب‌سازی دوباره بکشید", + "Easy Setup": "راه‌اندازی آسان", + "Edit": "ویرایش", + "Edit Combo": "ویرایش ترکیب", + "Edit Connection": "ویرایش اتصال", + "Edit Pricing": "ویرایش قیمت‌گذاری", + "Edit Proxy Pool": "ویرایش استخر پروکسی", + "Edit connection": "ویرایش اتصال", + "Edit hosts file manually to add the following entries:": "فایل hosts را به صورت دستی ویرایش کنید تا ورودی‌های زیر را اضافه کنید:", + "Email": "ایمیل", + "Embedding": "تعبیه", + "Embeddings": "تعبیه‌ها", + "Enable": "فعال‌سازی", + "Enable DNS per tool below to activate interception": "DNS را برای هر ابزار در زیر فعال کنید تا رهگیری فعال شود", + "Enable DNS to edit model mappings": "برای ویرایش نگاشت‌های مدل، DNS را فعال کنید", + "Enable Observability": "فعال‌سازی مشاهده‌پذیری", + "Enable OpenAI API": "فعال‌سازی OpenAI API", + "Enable Tunnel": "فعال‌سازی تونل", + "Enable connections that still have quota on the current page": "فعال‌سازی اتصالاتی که هنوز در صفحه فعلی سهمیه دارند", + "Enable provider": "فعال‌سازی ارائه‌دهنده", + "Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.", + "Encrypted": "رمزگذاری شده", + "End Date": "تاریخ پایان", + "End-to-end TLS via Cloudflare": "TLS انتها به انتها از طریق Cloudflare", + "Endpoint": "نقطه پایانی", + "Endpoint & Key": "نقطه پایانی و کلید", + "Endpoint is exposed without an API key.": "نقطه پایانی بدون کلید API در معرض دسترسی است.", + "Enter current password": "رمز عبور فعلی را وارد کنید", + "Enter model id": "شناسه مدل را وارد کنید", + "Enter model id (provider-specific)": "شناسه مدل را وارد کنید (مخصوص ارائه‌دهنده)", + "Enter new API key": "کلید API جدید را وارد کنید", + "Enter new password": "رمز عبور جدید را وارد کنید", + "Enter or pick API key": "کلید API را وارد یا انتخاب کنید", + "Enter password": "رمز عبور را وارد کنید", + "Enter sudo password": "رمز عبور sudo را وارد کنید", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "شناسه مدل را دقیقاً همانطور که نقطه پایانی سازگار شما انتظار دارد وارد کنید. این مدل به عنوان پیش‌فرض اتصال ذخیره می‌شود.", + "Enter your API key": "کلید API خود را وارد کنید", + "Enter your current password to": "رمز عبور فعلی خود را وارد کنید تا", + "Enter your password to access the dashboard": "برای دسترسی به داشبورد رمز عبور خود را وارد کنید", + "Error": "خطا", + "Est. Cost": "هزینه تقریبی", + "Estimated, not actual billing": "تخمینی، نه صورتحساب واقعی", + "Everything you need to manage your AI infrastructure efficiently.": "هر آنچه برای مدیریت کارآمد زیرساخت هوش مصنوعی خود نیاز دارید.", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "هر آنچه برای مدیریت زیرساخت هوش مصنوعی خود در یک مکان نیاز دارید، ساخته شده برای مقیاس.", + "Example": "مثال", + "Experimental": "آزمایشی", + "Expires At": "منقضی می‌شود در", + "Expiring first": "ابتدا در حال انقضا", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "«ابتدا در حال انقضا» در حال حاضر حساب‌ها را در صفحه فعلی دوباره مرتب می‌کند. ترتیب بین صفحه‌ها همچنان از صفحه‌بندی backend پیروی می‌کند.", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "9Router محلی خود را به اینترنت نمایش دهید. نیازی به انتقال پورت یا IP ثابت نیست. URL نقطه پایانی را با تیم خود به اشتراک بگذارید یا از آن در Cursor، Cline و سایر ابزارهای هوش مصنوعی از هر جایی استفاده کنید.", + "Factory Droid - Manual Configuration": "Factory Droid - پیکربندی دستی", + "Factory Droid AI Assistant": "دستیار هوش مصنوعی Factory Droid", + "Factory Droid CLI not detected locally": "Factory Droid CLI در سیستم محلی شناسایی نشد", + "Factory Droid CLI not installed": "Factory Droid CLI نصب نشده است", + "Fail request if proxy is unreachable instead of falling back to direct.": "در صورت عدم دسترسی به پروکسی، درخواست را با شکست مواجه کنید به جای بازگشت به مستقیم.", + "Failed to apply settings": "اعمال تنظیمات ناموفق بود", + "Failed to create combo": "ایجاد ترکیب ناموفق بود", + "Failed to load changelog:": "بارگذاری تاریخچه تغییرات ناموفق بود:", + "Failed to load usage statistics.": "بارگذاری آمار مصرف ناموفق بود.", + "Failed to reset settings": "بازنشانی تنظیمات ناموفق بود", + "Failed to set alias": "تنظیم نام مستعار ناموفق بود", + "Failed to update combo": "به‌روزرسانی ترکیب ناموفق بود", + "Failed to update password": "به‌روزرسانی رمز عبور ناموفق بود", + "Failed to update proxy settings": "به‌روزرسانی تنظیمات پروکسی ناموفق بود", + "Fallback": "پشتیبان جایگزین", + "Fallback — tries models in order (next on failure)": "پشتیبان جایگزین — مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "Fallback — try in order": "پشتیبان جایگزین — به ترتیب امتحان کنید", + "Features": "ویژگی‌ها", + "Fetch Qoder Models": "دریافت مدل‌های Qoder", + "Fetching...": "در حال دریافت...", + "Files": "فایل‌ها", + "Filter accounts by status": "فیلتر حساب‌ها بر اساس وضعیت", + "Filter naming": "فیلتر نام‌گذاری", + "Filter naming requests": "فیلتر درخواست‌های نام‌گذاری", + "Filter quota providers": "فیلتر ارائه‌دهندگان سهمیه", + "Find MCPs →": "یافتن MCPها →", + "Find your Account ID in the right sidebar of": "شناسه حساب خود را در نوار کناری سمت راست پیدا کنید", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "شناسه حساب خود را در نوار کناری سمت راست dash.cloudflare.com پیدا کنید", + "First Page": "صفحه اول", + "Flush Interval (ms)": "فاصله تخلیه (میلی‌ثانیه)", + "For enterprise users with custom AWS IAM Identity Center.": "برای کاربران سازمانی با مرکز هویت AWS IAM سفارشی.", + "Forgot password? Open": "رمز عبور را فراموش کرده‌اید؟ باز کنید", + "Format": "فرمت", + "Found on the right side of the Cloudflare dashboard overview page.": "در سمت راست صفحه نمای کلی داشبورد Cloudflare یافت می‌شود.", + "Free": "رایگان", + "Free & Free Tier Providers": "ارائه‌دهندگان رایگان و لایه رایگان", + "Free Providers": "ارائه‌دهندگان رایگان", + "Free Tier": "لایه رایگان", + "Free Tier Providers": "ارائه‌دهندگان لایه رایگان", + "Free tier: 100,000 requests per day": "لایه رایگان: ۱۰۰٬۰۰۰ درخواست در روز", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "لایه رایگان: ۱۰۰ گیگابایت پهنای باند در ماه، ۵۰۰٬۰۰۰ فراخوانی لبه", + "Free tier: 1M requests & 100GiB outbound traffic per month": "لایه رایگان: ۱ میلیون درخواست و ۱۰۰ گیگابایت ترافیک خروجی در ماه", + "Fresh API key obtained": "کلید API جدید دریافت شد", + "Full shell access": "دسترسی کامل به شل", + "Fusion": "همجوشی", + "Fusion — panel + judge": "همجوشی — پنل + داور", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "همجوشی — همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند", + "Get 9Remote": "دریافت 9Remote", + "Get API Key": "دریافت کلید API", + "Get API Key →": "دریافت کلید API →", + "Get Started": "شروع کنید", + "Get Started in 30 Seconds": "شروع در ۳۰ ثانیه", + "Get started": "شروع کنید", + "Get started in seconds. Just install, open, and route.": "در چند ثانیه شروع کنید. فقط نصب کنید، باز کنید و مسیردهی کنید.", + "Get token →": "دریافت توکن →", + "GitHub": "GitHub", + "GitHub Account": "حساب GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - پیکربندی دستی", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE با MITM", + "GitLab Access Tokens": "توکن‌های دسترسی GitLab", + "GitLab Applications": "برنامه‌های GitLab", + "GitLab Base URL": "آدرس پایه GitLab", + "Go to": "رفتن به", + "Go to Roo Settings panel": "رفتن به پنل تنظیمات Roo", + "Google Account": "حساب Google", + "Google Antigravity IDE with MITM": "Google Antigravity IDE با MITM", + "Granted At": "اعطا شده در", + "Group models under one name, then pick a strategy per combo:": "مدل‌ها را تحت یک نام گروه‌بندی کنید، سپس برای هر ترکیب یک استراتژی انتخاب کنید:", + "Headroom proxy is reachable. You can enable the token saver.": "پروکسی Headroom قابل دسترسی است. می‌توانید ذخیره‌ساز توکن را فعال کنید.", + "Help Center": "مرکز راهنما", + "Hermes Agent - Manual Configuration": "Hermes Agent - پیکربندی دستی", + "Hermes Agent not detected locally": "Hermes Agent در سیستم محلی شناسایی نشد", + "Hide": "پنهان کردن", + "Hide key": "پنهان کردن کلید", + "High performance global routing and IP masking via Cloudflare Workers": "مسیریابی جهانی با کارایی بالا و پنهان‌سازی IP از طریق Cloudflare Workers", + "High-performance Rust-based coding agent harness": "چارچوب عامل کدنویسی مبتنی بر Rust با کارایی بالا", + "History": "تاریخچه", + "How 9Router Works": "نحوه عملکرد 9Router", + "How Pricing Works": "نحوه عملکرد قیمت‌گذاری", + "How it Works": "نحوه عملکرد", + "How it works:": "نحوه عملکرد:", + "How to Install": "نحوه نصب", + "How to generate API token:": "نحوه تولید توکن API:", + "How to generate your API Token:": "نحوه تولید توکن API خود:", + "How to get cookie:": "نحوه دریافت کوکی:", + "ID:": "شناسه:", + "IDC Start URL": "آدرس شروع IDC", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "اگر ارائه‌دهنده نقطه پایانی /models را ندارد، یک شناسه مدل را برای اعتبارسنجی از طریق chat/completions وارد کنید.", + "Image Generation": "تولید تصویر", + "Image to Text": "تصویر به متن", + "Import": "وارد کردن", + "Import Backup": "وارد کردن پشتیبان", + "Import CLIProxyAPI JSON": "وارد کردن CLIProxyAPI JSON", + "Import Token": "وارد کردن توکن", + "Importing...": "در حال وارد کردن...", + "In": "ورودی", + "In / Out": "ورودی/خروجی", + "Inactive": "غیرفعال", + "Inactive pools are ignored by runtime resolution.": "استخرهای غیرفعال توسط وضوح زمان اجرا نادیده گرفته می‌شوند.", + "Inc. All rights reserved.": "شرکت. تمام حقوق محفوظ است.", + "Initializing...": "در حال مقداردهی اولیه...", + "Input": "ورودی", + "Input Cost": "هزینه ورودی", + "Input Tokens": "توکن‌های ورودی", + "Input Tokens:": "توکن‌های ورودی:", + "Input:": "ورودی:", + "Install 9Router": "نصب 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "9Router را نصب کنید، ارائه‌دهندگان خود را از طریق داشبورد وب پیکربندی کنید و مسیردهی درخواست‌های هوش مصنوعی را شروع کنید.", + "Install Chrome extension": "نصب افزونه Chrome", + "Install Cline VS Code extension or CLI from": "افزونه یا CLI Cline VS Code را از نصب کنید", + "Install Kilo Code from": "Kilo Code را از نصب کنید", + "Install Qwen Code": "نصب Qwen Code", + "Install Tailscale": "نصب Tailscale", + "Install command:": "دستور نصب:", + "Install jcode to enable automatic configuration:": "jcode را نصب کنید تا پیکربندی خودکار فعال شود:", + "Install the Amp CLI using the package manager supported by your environment.": "Amp CLI را با استفاده از مدیر بسته پشتیبانی شده توسط محیط خود نصب کنید.", + "Install then click Start:": "نصب کنید سپس روی شروع کلیک کنید:", + "Install via npm:": "نصب از طریق npm:", + "Installation Guide": "راهنمای نصب", + "Installing Tailscale...": "در حال نصب Tailscale...", + "Interactive diagram visible on desktop": "نمودار تعاملی در دسکتاپ قابل مشاهده است", + "Intercept CLI tool traffic and route through 9Router": "ترافیک ابزار CLI را رهگیری کرده و از طریق 9Router مسیردهی کنید", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ترافیک Antigravity را از طریق تغییر مسیر DNS رهگیری می‌کند و به شما امکان می‌دهد مدل‌ها را از طریق 9Router مسیردهی مجدد کنید.", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "درخواست‌های نام‌گذاری موضوع Claude Code را رهگیری کرده و یک پاسخ ساختگی به صورت محلی برمی‌گرداند و توکن‌های API را ذخیره می‌کند.", + "Invalid": "نامعتبر", + "Invalid password": "رمز عبور نامعتبر", + "Issuer URL": "آدرس صادرکننده", + "JSON Response": "پاسخ JSON", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "به توسعه‌دهندگانی بپیوندید که با 9Router یکپارچه‌سازی‌های هوش مصنوعی خود را ساده‌سازی می‌کنند. منبع باز و رایگان برای شروع.", + "Judge": "داور", + "Just now": "همین الان", + "KB per field": "کیلوبایت در هر فیلد", + "Keep the legacy password login.": "ورود با رمز عبور قدیمی را حفظ کنید.", + "Key Name": "نام کلید", + "KiRo dashboard": "داشبورد KiRo", + "Kill & Start": "پایان و شروع", + "Kill this process to start MITM Server?": "برای راه‌اندازی سرور MITM این فرآیند را پایان دهید؟", + "Kilo Code - Manual Configuration": "Kilo Code - پیکربندی دستی", + "Kilo Code AI Assistant": "دستیار هوش مصنوعی Kilo Code", + "Kilo Code not detected locally": "Kilo Code در سیستم محلی شناسایی نشد", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE شناسایی نشد. لطفاً توکن بازسازی خود را به صورت دستی بچسبانید.", + "Kiro IDE with MITM": "Kiro IDE با MITM", + "Language": "زبان", + "Languages": "زبان‌ها", + "Last Page": "آخرین صفحه", + "Last Used": "آخرین استفاده", + "Last tested:": "آخرین آزمایش:", + "Last updated:": "آخرین به‌روزرسانی:", + "Latency": "تاخیر", + "Latency:": "تاخیر:", + "Lazy senior dev": "توسعه‌دهنده ارشد تنبل", + "Lean": "ساده", + "Leave blank to keep existing secret": "برای حفظ راز موجود خالی بگذارید", + "Leave blank to use": "برای استفاده خالی بگذارید", + "Leave empty for public PKCE app": "برای برنامه PKCE عمومی خالی بگذارید", + "Leave empty to inherit existing env proxy (if any).": "برای ارث‌بری از پروکسی موجود محیط، خالی بگذارید (در صورت وجود).", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "فیلدهای پروکسی دستی قدیمی هنوز برای سازگاری با گذشته توسط API پذیرفته می‌شوند.", + "Legacy:": "قدیمی:", + "Legal": "قانونی", + "Live server console output": "خروجی کنسول سرور زنده", + "Load": "بارگذاری", + "Loading logs...": "در حال بارگذاری لاگ‌ها...", + "Loading models from provider...": "در حال بارگذاری مدل‌ها از ارائه‌دهنده...", + "Loading pricing data...": "در حال بارگذاری داده‌های قیمت‌گذاری...", + "Loading registry...": "در حال بارگذاری رجیستری...", + "Loading reset credits...": "در حال بارگذاری اعتبارات بازنشانی...", + "Loading...": "در حال بارگذاری...", + "Local": "محلی", + "Local Mode": "حالت محلی", + "Local Mode - All data stored on your machine": "حالت محلی - تمام داده‌ها روی دستگاه شما ذخیره می‌شوند", + "Local Plugins": "افزونه‌های محلی", + "Locked. Retry in": "قفل شد. دوباره تلاش کنید در", + "Login": "ورود", + "Login Button Label": "برچسب دکمه ورود", + "Login URL": "آدرس ورود", + "Login to your account": "وارد حساب خود شوید", + "Login with your GitHub account (manual callback).": "با حساب GitHub خود وارد شوید (بازگشت دستی).", + "Login with your Google account (manual callback).": "با حساب Google خود وارد شوید (بازگشت دستی).", + "Logout": "خروج", + "Logs": "لاگ‌ها", + "Logs are loaded from the request history database.": "لاگ‌ها از پایگاه داده تاریخچه درخواست بارگذاری می‌شوند.", + "Logs are saved to log.txt in the application data directory.": "لاگ‌ها در log.txt در دایرکتوری داده برنامه ذخیره می‌شوند.", + "MIT License": "مجوز MIT", + "MITM": "MITM", + "MITM Proxy": "پروکسی MITM", + "MITM Server": "سرور MITM", + "MITM Tools": "ابزارهای MITM", + "Machine ID": "شناسه ماشین", + "Machine ID will be auto-filled...": "شناسه ماشین به‌طور خودکار پر می‌شود...", + "Make sure Cursor IDE has been opened at least once, then click": "مطمئن شوید Cursor IDE حداقل یک بار باز شده است، سپس کلیک کنید", + "Manage": "مدیریت", + "Manage reusable per-connection proxies and bind them to provider connections.": "پروکسی‌های قابل استفاده مجدد به ازای هر اتصال را مدیریت کرده و آنها را به اتصالات ارائه‌دهنده متصل کنید.", + "Manage your AI provider connections": "مدیریت اتصالات ارائه‌دهندگان هوش مصنوعی خود", + "Manage your Embedding providers": "مدیریت ارائه‌دهندگان تعبیه خود", + "Manage your Image to Text providers": "مدیریت ارائه‌دهندگان تصویر به متن خود", + "Manage your Music providers": "مدیریت ارائه‌دهندگان موسیقی خود", + "Manage your Speech To Text providers": "مدیریت ارائه‌دهندگان گفتار به متن خود", + "Manage your Text To Speech providers": "مدیریت ارائه‌دهندگان متن به گفتار خود", + "Manage your Text to Image providers": "مدیریت ارائه‌دهندگان متن به تصویر خود", + "Manage your Video providers": "مدیریت ارائه‌دهندگان ویدیوی خود", + "Manage your Web Fetch providers": "مدیریت ارائه‌دهندگان دریافت وب خود", + "Manage your Web Search providers": "مدیریت ارائه‌دهندگان جستجوی وب خود", + "Manage your preferences": "مدیریت تنظیمات شخصی", + "Manage your proxy pool configurations": "مدیریت پیکربندی‌های استخر پروکسی خود", + "Manual / current endpoint": "دستی / نقطه پایانی فعلی", + "Manual Callback Required": "بازگشت دستی مورد نیاز است", + "Manual Config": "پیکربندی دستی", + "Manual configuration is still available if 9router is deployed on a remote server.": "اگر 9router روی یک سرور راه دور مستقر شده باشد، پیکربندی دستی همچنان در دسترس است.", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "نام‌های میان‌نویس Amp مانند g25p یا cs45 را به نام‌های مستعار 9Router در پیکربندی محلی خود نگاشت کنید.", + "Mask (URL)": "ماسک (URL)", + "Max JSON Size (KB)": "حداکثر اندازه JSON (کیلوبایت)", + "Max Records": "حداکثر تعداد رکوردها", + "Maximum request detail records to keep (older records are auto-deleted)": "حداکثر تعداد رکوردهای جزئیات درخواست برای نگهداری (رکوردهای قدیمی‌تر به صورت خودکار حذف می‌شوند)", + "Maximum size for each JSON field (request/response) before truncation": "حداکثر اندازه برای هر فیلد JSON (درخواست/پاسخ) قبل از برش", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "حداکثر زمان انتظار قبل از تخلیه بافر (از از دست رفتن داده در ترافیک کم جلوگیری می‌کند)", + "Media Providers": "ارائه‌دهندگان رسانه", + "Menu": "منو", + "Message AI": "ارسال پیام به هوش مصنوعی", + "Messages": "پیام‌ها", + "Messages API": "API پیام‌ها", + "MiniMax": "MiniMax", + "Model": "مدل", + "Model Fallback": "پشتیبان مدل", + "Model ID": "شناسه مدل", + "Model ID (from OpenRouter)": "شناسه مدل (از OpenRouter)", + "Model ID (optional)": "شناسه مدل (اختیاری)", + "Model Status": "وضعیت مدل", + "Model combos": "ترکیبات مدل", + "Model combos with fallback": "ترکیبات مدل با پشتیبان جایگزین", + "Model is reachable": "مدل قابل دسترسی است", + "Model list is filtered from connected providers.": "لیست مدل‌ها از ارائه‌دهندگان متصل فیلتر شده است.", + "Model mappings will be available soon.": "نگاشت‌های مدل به زودی در دسترس خواهند بود.", + "Model not reachable": "مدل قابل دسترسی نیست", + "Model:": "مدل:", + "Models": "مدل‌ها", + "Monitor your API usage, token consumption, and request logs": "نظارت بر مصرف API، مصرف توکن و لاگ درخواست‌ها", + "More on GitHub": "بیشتر در GitHub", + "Move down": "پایین آوردن", + "Move up": "بالا بردن", + "Music": "موسیقی", + "My Profile": "پروفایل من", + "N/A": "ناموجود", + "NPM": "NPM", + "Name": "نام", + "Name is required": "نام الزامی است", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "پشتیبانی بومی از ابزارهای CLI برای Cursor، Claude، Copilot و بیشتر.", + "Navigate to home": "رفتن به صفحه اصلی", + "Network": "شبکه", + "Network Error": "خطای شبکه", + "Network error": "خطای شبکه", + "Never": "هرگز", + "New Password": "رمز عبور جدید", + "New password": "رمز عبور جدید", + "Next": "بعدی", + "Next accounts page": "صفحه بعدی حساب‌ها", + "No API keys - Create one in Keys page": "بدون کلید API - یکی در صفحه کلیدها ایجاد کنید", + "No API keys yet": "هنوز کلید API وجود ندارد", + "No MCPs added": "هیچ MCP اضافه نشده است", + "No Providers Connected": "هیچ ارائه‌دهنده‌ای متصل نیست", + "No Proxy": "بدون پروکسی", + "No active connections found for this group.": "هیچ اتصال فعالی برای این گروه یافت نشد.", + "No active providers": "هیچ ارائه‌دهنده فعالی وجود ندارد", + "No active proxy pools available. Create one in Proxy Pools page first.": "هیچ استخر پروکسی فعالی در دسترس نیست. ابتدا یکی را در صفحه استخرهای پروکسی ایجاد کنید.", + "No authentication required": "نیازی به احراز هویت نیست", + "No combos yet": "هنوز ترکیبی وجود ندارد", + "No combos yet.": "هنوز ترکیبی وجود ندارد.", + "No compatible providers added yet": "هنوز هیچ ارائه‌دهنده سازگاری اضافه نشده است", + "No connections": "بدون اتصال", + "No connections yet": "هنوز اتصالی وجود ندارد", + "No console logs yet.": "هنوز لاگ کنسولی وجود ندارد.", + "No conversations yet.": "هنوز گفتگویی وجود ندارد.", + "No custom providers": "هیچ ارائه‌دهنده سفارشی وجود ندارد", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "هیچ ارائه‌دهنده سفارشی وجود ندارد — از دکمه‌های بالا برای افزودن نقاط پایانی سازگار با OpenAI/Anthropic استفاده کنید", + "No data for this period": "داده‌ای برای این دوره وجود ندارد", + "No key configured": "هیچ کلیدی پیکربندی نشده است", + "No language selected": "هیچ زبانی انتخاب نشده است", + "No languages found.": "هیچ زبانی یافت نشد.", + "No logs recorded yet.": "هنوز هیچ لاگی ثبت نشده است.", + "No model selected.": "هیچ مدلی انتخاب نشده است.", + "No models": "هیچ مدلی", + "No models added yet": "هنوز هیچ مدلی اضافه نشده است", + "No models configured": "هیچ مدلی پیکربندی نشده است", + "No models found": "هیچ مدلی یافت نشد", + "No models match your filter.": "هیچ مدلی با فیلتر شما مطابقت ندارد.", + "No models selected": "هیچ مدلی انتخاب نشده است", + "No port forwarding needed": "نیازی به انتقال پورت نیست", + "No pricing data available": "هیچ داده قیمت‌گذاری در دسترس نیست", + "No providers connected": "هیچ ارائه‌دهنده‌ای متصل نیست", + "No providers match your search": "هیچ ارائه‌دهنده‌ای با جستجوی شما مطابقت ندارد", + "No providers support": "هیچ ارائه‌دهنده‌ای پشتیبانی نمی‌کند", + "No providers yet.": "هنوز هیچ ارائه‌دهنده‌ای وجود ندارد.", + "No providers.": "هیچ ارائه‌دهنده‌ای وجود ندارد.", + "No proxy pool entries yet": "هنوز هیچ ورودی استخر پروکسی وجود ندارد", + "No proxy:": "بدون پروکسی:", + "No quota data available": "هیچ داده سهمیه‌ای در دسترس نیست", + "No request details found": "هیچ جزئیات درخواستی یافت نشد", + "No requests yet.": "هنوز هیچ درخواستی وجود ندارد.", + "No reset credit details returned for this account.": "هیچ جزئیات اعتبار بازنشانی برای این حساب بازگردانده نشد.", + "No results": "نتیجه‌ای یافت نشد", + "No servers match filter": "هیچ سروری با فیلتر مطابقت ندارد", + "No tools advertised by server.": "هیچ ابزاری توسط سرور اعلام نشده است.", + "No usage yet.": "هنوز مصرفی وجود ندارد.", + "None": "هیچکدام", + "None (unbind all)": "هیچکدام (لغو پیوند همه)", + "Not configured": "پیکربندی نشده", + "Not installed": "نصب نشده", + "Notice": "توجه", + "Nous Research self-improving AI agent": "عامل هوش مصنوعی خودبهبود Nous Research", + "Number of items to accumulate before writing to database (higher = better performance)": "تعداد موارد قبل از نوشتن در پایگاه داده (بیشتر = عملکرد بهتر)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth و کلیدهای API", + "OAuth Account": "حساب OAuth", + "OAuth App": "برنامه OAuth", + "OAuth Providers": "ارائه‌دهندگان OAuth", + "OAuth required": "نیاز به OAuth", + "OIDC Dashboard Login": "ورود به داشبورد با OIDC", + "OIDC active": "OIDC فعال است", + "OIDC login is currently active. Password login is disabled until you switch back.": "ورود با OIDC در حال حاضر فعال است. ورود با رمز عبور تا زمانی که تغییر دهید غیرفعال است.", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "ورود با OIDC فعال است، اما فیلدهای صادرکننده/مشتری هنوز پیکربندی نشده‌اند. ورود با رمز عبور همچنان برای بازیابی در دسترس است.", + "OIDC only": "فقط OIDC", + "Observability": "مشاهده‌پذیری", + "Office Proxy": "پروکسی اداری", + "Ollama Host URL": "آدرس میزبان Ollama", + "One Endpoint for": "یک نقطه پایانی برای", + "One key per line. Format:": "یک کلید در هر خط. فرمت:", + "One-to-one (rotate)": "یک به یک (چرخش)", + "Only from connected providers": "فقط از ارائه‌دهندگان متصل", + "Only letters, numbers, - and _ allowed": "فقط حروف، اعداد، - و _ مجاز است", + "Only letters, numbers, -, _ and .": "فقط حروف، اعداد، -، _ و .", + "Only letters, numbers, -, _ and . allowed": "فقط حروف، اعداد، -، _ و . مجاز است", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "به ازای هر گره سازگار فقط یک اتصال مجاز است. در صورت نیاز به اتصالات بیشتر، گره دیگری اضافه کنید.", + "Open": "باز کردن", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference را باز کنید، سپس به اینجا بازگردید.", + "Open Claw - Manual Configuration": "Open Claw - پیکربندی دستی", + "Open Claw AI Assistant": "دستیار هوش مصنوعی Open Claw", + "Open Claw CLI not detected locally": "Open Claw CLI در سیستم محلی شناسایی نشد", + "Open Claw CLI not installed": "Open Claw CLI نصب نشده است", + "Open Continue configuration file": "باز کردن فایل پیکربندی Continue", + "Open Dashboard": "باز کردن داشبورد", + "Open DevTools (F12) → Application/Storage → Cookies": "DevTools (F12) → Application/Storage → Cookies را باز کنید", + "Open Settings": "باز کردن تنظیمات", + "Open platform.iflow.cn in your browser": "platform.iflow.cn را در مرورگر خود باز کنید", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "صداهای OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "سازگار با OpenAI (تولید)", + "OpenAI Compatible Details": "جزئیات سازگاری با OpenAI", + "OpenAI Intermediate": "قالب میانی OpenAI", + "OpenAI Response": "پاسخ OpenAI", + "OpenCode - Manual Configuration": "OpenCode - پیکربندی دستی", + "OpenCode AI Terminal Assistant": "دستیار ترمینال هوش مصنوعی OpenCode", + "OpenCode CLI not detected locally": "OpenCode CLI در سیستم محلی شناسایی نشد", + "OpenCode CLI not installed": "OpenCode CLI نصب نشده است", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter از هر مدلی پشتیبانی می‌کند. مدل‌ها را اضافه کرده و برای دسترسی سریع نام مستعار ایجاد کنید.", + "Optional SSO via Authentik/Keycloak/Google": "SSO اختیاری از طریق Authentik/Keycloak/Google", + "Or paste callback URL manually": "یا آدرس پاسخ بازگشت را به صورت دستی بچسبانید", + "Organization": "سازمان", + "Organization Domain": "دامنه سازمان", + "Organization ID": "شناسه سازمان", + "Organization Token": "توکن سازمان", + "Organization Tokens": "توکن‌های سازمان", + "Other": "سایر", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "موتور ما پرامپت را تحلیل کرده و از طریق لایه‌های اشتراک، ارزان و رایگان ارائه‌دهنده با بازگشت خودکار مسیردهی می‌کند.", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "موتور ما پرامپت را تحلیل کرده، سلامت ارائه‌دهنده را بررسی کرده و برای کمترین تاخیر یا هزینه مسیردهی می‌کند.", + "Out": "خروجی", + "Outbound Proxy": "پروکسی خروجی", + "Output": "خروجی", + "Output Cost": "هزینه خروجی", + "Output Format": "قالب خروجی", + "Output Tokens": "توکن‌های خروجی", + "Output Tokens:": "توکن‌های خروجی:", + "Output:": "خروجی:", + "Overview": "بررسی کلی", + "Paid": "پولی", + "Partial preview": "پیش‌نمایش جزئی", + "Password": "رمز عبور", + "Password + OIDC active": "رمز عبور + OIDC فعال است", + "Password and OIDC login are both active.": "ورود با رمز عبور و OIDC هر دو فعال هستند.", + "Password and OIDC login are both enabled.": "ورود با رمز عبور و OIDC هر دو فعال شده‌اند.", + "Password only": "فقط رمز عبور", + "Password updated successfully": "رمز عبور با موفقیت به‌روزرسانی شد", + "Passwords do not match": "رمزهای عبور مطابقت ندارند", + "Paste Proxy List (One per line)": "چسباندن لیست پروکسی (یک در هر خط)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "یک کلید API طولانی‌مدت Kiro/CodeWhisperer را بچسبانید. در برابر AWS تأیید شده و مستقیماً به عنوان اعتبارنامه Bearer ذخیره می‌شود (بدون بازسازی).", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "JSON احراز هویت external_idp را از ورود Microsoft CLIProxyAPI/Kiro بچسبانید.", + "Paste it below": "آن را در زیر بچسبانید", + "Paste refresh token from Kiro IDE.": "توکن بازسازی را از Kiro IDE بچسبانید.", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "JSON احراز هویت Kiro CLIProxyAPI حاوی auth_method=external_idp را بچسبانید. فقط نقاط پایانی توکن ورود Microsoft پذیرفته می‌شوند.", + "Paste the URL from your browser address bar": "URL را از نوار آدرس مرورگر خود بچسبانید", + "Paste the command into your terminal and press Enter.": "دستور را در ترمینال خود بچسبانید و Enter را فشار دهید.", + "Paste this to your AI:": "این را به هوش مصنوعی خود بچسبانید:", + "Paste your Kiro API key...": "کلید API Kiro خود را بچسبانید...", + "Pause API Key": "مکث کلید API", + "Pause key": "مکث کلید", + "Paused": "مکث شده", + "Permissions": "مجوزها", + "Personal Access Token": "توکن دسترسی شخصی", + "Pick the model that fuses panel answers": "مدلی را انتخاب کنید که پاسخ‌های پنل را ترکیب می‌کند", + "Please add an active Qoder connection first": "لطفاً ابتدا یک اتصال Qoder فعال اضافه کنید", + "Please add and connect providers first to configure CLI tools.": "لطفاً ابتدا ارائه‌دهندگان را اضافه و متصل کنید تا ابزارهای CLI پیکربندی شوند.", + "Please copy the URL from the address bar and paste it in the application.": "لطفاً URL را از نوار آدرس کپی کرده و در برنامه بچسبانید.", + "Please enter a Proxy URL to test": "لطفاً یک آدرس پروکسی برای آزمایش وارد کنید", + "Please install Claude CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Claude CLI را نصب کنید.", + "Please install Codex CLI to use auto-apply feature.": "لطفاً برای استفاده از ویژگی اعمال خودکار، Codex CLI را نصب کنید.", + "Please install Factory Droid CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Factory Droid CLI را نصب کنید.", + "Please install Open Claw CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Open Claw CLI را نصب کنید.", + "Please install OpenCode CLI to use auto-apply feature.": "لطفاً برای استفاده از ویژگی اعمال خودکار، OpenCode CLI را نصب کنید.", + "Please wait while we complete the authorization.": "لطفاً در حالی که مجوز را تکمیل می‌کنیم، منتظر بمانید.", + "Point your CLI tools to http://localhost:20128": "ابزارهای CLI خود را به http://localhost:20128 هدایت کنید", + "Pool:": "استخر:", + "Popup blocked? Enter URL manually": "پنجره بازشو مسدود شد؟ URL را به صورت دستی وارد کنید", + "Port 443 Already In Use": "پورت ۴۴۳ در حال استفاده است", + "Port 443 is currently used by another process:": "پورت ۴۴۳ در حال حاضر توسط فرآیند دیگری استفاده می‌شود:", + "Powerful Features": "ویژگی‌های قدرتمند", + "Prefix": "پیشوند", + "Preset": "تنظیم از پیش", + "Prev": "قبلی", + "Preview": "پیش‌نمایش", + "Previous accounts page": "صفحه قبلی حساب‌ها", + "Pricing": "قیمت‌گذاری", + "Pricing Configuration": "پیکربندی قیمت‌گذاری", + "Pricing Format:": "فرمت قیمت‌گذاری:", + "Pricing Rates Format": "قالب نرخ‌های قیمت‌گذاری", + "Pricing Settings": "تنظیمات قیمت‌گذاری", + "Priority": "اولویت", + "Privacy Policy": "سیاست حفظ حریم خصوصی", + "Probing server for tools...": "در حال بررسی سرور برای ابزارها...", + "Processing...": "در حال پردازش...", + "Product": "محصول", + "Production Key": "کلید تولید", + "Project Name": "نام پروژه", + "Prompt": "پرامپت", + "Provider": "ارائه‌دهنده", + "Provider Details": "جزئیات ارائه‌دهنده", + "Provider Limits": "محدودیت‌های ارائه‌دهنده", + "Provider Response": "پاسخ ارائه‌دهنده", + "Provider not found": "ارائه‌دهنده یافت نشد", + "Provider test failed": "آزمایش ارائه‌دهنده ناموفق بود", + "Provider:": "ارائه‌دهنده:", + "Providers": "ارائه‌دهندگان", + "Proxy": "پروکسی", + "Proxy Action": "عملیات پروکسی", + "Proxy Pool": "استخر پروکسی", + "Proxy Pools": "استخرهای پروکسی", + "Proxy URL": "آدرس پروکسی", + "Proxy disabled": "پروکسی غیرفعال شد", + "Proxy enabled": "پروکسی فعال شد", + "Proxy pool created": "استخر پروکسی ایجاد شد", + "Proxy pool deleted": "استخر پروکسی حذف شد", + "Proxy pool updated": "استخر پروکسی به‌روزرسانی شد", + "Proxy settings applied": "تنظیمات پروکسی اعمال شد", + "Proxy test OK": "آزمایش پروکسی موفق بود", + "Proxy test failed": "آزمایش پروکسی ناموفق بود", + "Proxy test passed": "آزمایش پروکسی گذرانده شد", + "Purpose:": "هدف:", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "برای حالت مدیریت محلی به Python >= 3.10 نیاز است. ابتدا Python را نصب کنید یا از آدرس پروکسی خارجی استفاده کنید.", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "برای حالت مدیریت محلی به Python ≥ 3.10 نیاز است. ابتدا Python را نصب کنید یا از آدرس پروکسی خارجی استفاده کنید.", + "Quota Tracker": "پیگیری سهمیه", + "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code از انواع مختلف ارائه‌دهندگان (openai، anthropic، gemini) از طریق modelProviders در settings.json پشتیبانی می‌کند. 9Router به عنوان یک نقطه پایانی سازگار با OpenAI کار می‌کند.", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "لایه رایگان OAuth Qwen در ۲۰۲۶-۰۴-۱۵ متوقف شد. به جای آن از 9Router با ارائه‌دهندگان alicode/openrouter/anthropic/gemini استفاده کنید.", + "Rate Limited": "محدودیت نرخ", + "Read Documentation": "مطالعه مستندات", + "Reading from AWS SSO cache": "خواندن از حافظه پنهان AWS SSO", + "Reading from Cursor IDE database": "خواندن از پایگاه داده Cursor IDE", + "Ready": "آماده", + "Ready to Simplify Your AI Infrastructure?": "آماده ساده‌سازی زیرساخت هوش مصنوعی خود هستید؟", + "Ready to route! ✓": "آماده برای مسیردهی! ✓", + "Ready! Requests route automatically through your configured providers.": "آماده! درخواست‌ها به‌طور خودکار از طریق ارائه‌دهندگان پیکربندی شده شما مسیردهی می‌شوند.", + "Reasoning": "استدلال", + "Reasoning:": "استدلال:", + "Recent Requests": "درخواست‌های اخیر", + "Recent chats": "گفتگوهای اخیر", + "Recheck": "بررسی مجدد", + "Recommended for most users. Free AWS account required.": "توصیه شده برای اکثر کاربران. نیاز به حساب رایگان AWS دارد.", + "Record request details for inspection in the logs view": "ثبت جزئیات درخواست برای بازرسی در نمای لاگ‌ها", + "Redirect URI": "URI تغییر مسیر", + "Ref Image (URL)": "تصویر مرجع (URL)", + "Refresh": "تازه‌سازی", + "Refresh All": "تازه‌سازی همه", + "Refresh Token": "توکن بازسازی", + "Refresh all": "تازه‌سازی همه", + "Refresh quota": "تازه‌سازی سهمیه", + "Region": "منطقه", + "Reload Page": "بارگذاری مجدد صفحه", + "Reload VS Code after applying for changes to take effect.": "پس از اعمال، VS Code را دوباره بارگذاری کنید تا تغییرات اعمال شوند.", + "Remaining": "باقیمانده", + "Remote": "دور", + "Remove": "حذف", + "Remove attachment": "حذف پیوست", + "Remove custom model": "حذف مدل سفارشی", + "Remove model": "حذف مدل", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "جایگزین WebSearch/WebFetch داخلی می‌شود. به‌طور خودکار موارد تکراری را از لیست ابزارها حذف می‌کند.", + "Replay request flow — matches log files": "پخش مجدد جریان درخواست — مطابق با فایل‌های لاگ", + "Request": "درخواست", + "Request Details": "جزئیات درخواست", + "Request Logs": "لاگ‌های درخواست", + "Requests": "درخواست‌ها", + "Requests without a valid key will be rejected": "درخواست‌های بدون کلید معتبر رد می‌شوند", + "Require API key": "نیاز به کلید API", + "Require OIDC for dashboard access.": "برای دسترسی به داشبورد به OIDC نیاز است.", + "Require login": "نیاز به ورود", + "Required for SSL certificate and DNS configuration": "برای گواهی SSL و پیکربندی DNS مورد نیاز است", + "Required for SSL certificate and server startup": "برای گواهی SSL و راه‌اندازی سرور مورد نیاز است", + "Required to modify /etc/hosts and flush DNS cache": "برای تغییر /etc/hosts و پاک کردن حافظه پنهان DNS مورد نیاز است", + "Required. A friendly label for this node.": "الزامی. یک برچسب دوستانه برای این گره.", + "Required. Used as the provider prefix for model IDs.": "الزامی. به عنوان پیشوند ارائه‌دهنده برای شناسه‌های مدل استفاده می‌شود.", + "Requires \"Workers Scripts: Edit\" permission.": "نیاز به مجوز \"Workers Scripts: Edit\" دارد.", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "نیاز به شناسه حساب Cloudflare و یک توکن Workers API (مجوز ویرایش Workers) دارد", + "Requires Cursor Pro account to use this feature.": "برای استفاده از این ویژگی به حساب Cursor Pro نیاز است.", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "به نصب jcode نیاز دارد. نصب از طریق: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "نیاز به پورت خروجی ۷۸۴۴ (TCP/UDP) دارد. اتصال ممکن است ۱۰-۳۰ ثانیه طول بکشد.", + "Reset": "بازنشانی", + "Reset Codex limit?": "بازنشانی محدودیت Codex؟", + "Reset Password to Default": "بازنشانی رمز عبور به پیش‌فرض", + "Reset judge to Auto": "بازنشانی داور به خودکار", + "Reset time": "زمان بازنشانی", + "Reset to Defaults": "بازنشانی به پیش‌فرض", + "Reset to default": "بازنشانی به پیش‌فرض", + "Resources": "منابع", + "Response": "پاسخ", + "Response Format": "قالب پاسخ", + "Responses": "پاسخ‌ها", + "Responses API": "API پاسخ‌ها", + "Restart": "راه‌اندازی مجدد", + "Restore model": "بازیابی مدل", + "Resume key": "ادامه کلید", + "Retry": "تلاش مجدد", + "Risk Notice": "اطلاعیه ریسک", + "Roo AI Assistant": "دستیار هوش مصنوعی Roo", + "Rotate providers across requests instead of strict fallback order.": "ارائه‌دهندگان را در بین درخواست‌ها به جای ترتیب بازگشت دقیق، بچرخانید.", + "Round Robin": "چرخشی", + "Round Robin — rotate": "چرخشی — چرخش", + "Round Robin — rotates models across requests to spread load": "چرخشی — مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "درخواست‌های هوش مصنوعی را از طریق لایه‌های اشتراک، ارزان و رایگان با بازگشت خودکار مسیردهی کنید. یک نقطه پایانی برای Claude، GPT، Gemini و بیشتر.", + "Route Requests": "مسیردهی درخواست‌ها", + "Routing Strategy": "استراتژی مسیردهی", + "Rows:": "ردیف‌ها:", + "Run": "اجرا", + "Run npx command to start the server instantly": "دستور npx را برای راه‌اندازی فوری سرور اجرا کنید", + "Run this command in your terminal, then click": "این دستور را در ترمینال خود اجرا کنید، سپس کلیک کنید", + "Running": "در حال اجرا", + "Running on your machine": "در حال اجرا روی دستگاه شما", + "Runtime": "زمان اجرا", + "SSE URL": "آدرس SSE", + "START HERE": "از اینجا شروع کنید", + "Save": "ذخیره", + "Save Changes": "ذخیره تغییرات", + "Save Config": "ذخیره پیکربندی", + "Save Mappings": "ذخیره نگاشت‌ها", + "Save auth mode": "ذخیره حالت احراز هویت", + "Save current Base URL and API key as a browser-local preset": "ذخیره آدرس پایه و کلید API فعلی به عنوان یک تنظیم از پیش محلی مرورگر", + "Save this key now!": "این کلید را همین حالا ذخیره کنید!", + "Saved": "ذخیره شد", + "Saving": "در حال ذخیره", + "Saving...": "در حال ذخیره...", + "Scan QR to connect instantly": "برای اتصال فوری، QR را اسکن کنید", + "Scopes": "حوزه‌ها", + "Screen sharing": "اشتراک‌گذاری صفحه", + "Scroll down to": "به پایین اسکرول کنید تا", + "Search by name or description...": "جستجو بر اساس نام یا توضیحات...", + "Search language...": "جستجوی زبان...", + "Search model id": "جستجوی شناسه مدل", + "Search providers...": "جستجوی ارائه‌دهندگان...", + "Search...": "جستجو...", + "Security": "امنیت", + "Security required: ": "نیاز به امنیت: ", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "خطر امنیتی: رمز عبور تنظیم نشده است. هنگام ورود از راه دور از شما خواسته می‌شود یک رمز عبور تنظیم کنید.", + "Select": "انتخاب", + "Select All": "انتخاب همه", + "Select Cowork Model": "انتخاب مدل Cowork", + "Select Endpoint": "انتخاب نقطه پایانی", + "Select Judge Model": "انتخاب مدل داور", + "Select Language": "انتخاب زبان", + "Select Model": "انتخاب مدل", + "Select Model for Cline": "انتخاب مدل برای Cline", + "Select Model for Codex": "انتخاب مدل برای Codex", + "Select Model for DeepSeek TUI": "انتخاب مدل برای DeepSeek TUI", + "Select Model for Factory Droid": "انتخاب مدل برای Factory Droid", + "Select Model for GitHub Copilot": "انتخاب مدل برای GitHub Copilot", + "Select Model for Hermes Agent": "انتخاب مدل برای Hermes Agent", + "Select Model for Kilo Code": "انتخاب مدل برای Kilo Code", + "Select Model for Open Claw": "انتخاب مدل برای Open Claw", + "Select Model for OpenCode": "انتخاب مدل برای OpenCode", + "Select Model for jcode": "انتخاب مدل برای jcode", + "Select Provider": "انتخاب ارائه‌دهنده", + "Select Subagent Model for Codex": "انتخاب مدل زیرعامل برای Codex", + "Select Subagent Model for OpenCode": "انتخاب مدل زیرعامل برای OpenCode", + "Select a provider": "یک ارائه‌دهنده انتخاب کنید", + "Select all": "انتخاب همه", + "Select language": "انتخاب زبان", + "Select models to add": "مدل‌ها را برای افزودن انتخاب کنید", + "Select one or more connections, then click Proxy Action.": "یک یا چند اتصال را انتخاب کنید، سپس روی عملیات پروکسی کلیک کنید.", + "Select to pre-fill, then edit model ID in the input": "برای پیش‌پر کردن انتخاب کنید، سپس شناسه مدل را در ورودی ویرایش کنید", + "Select your": "خود را انتخاب کنید", + "Selected connections have mixed proxy bindings": "اتصالات انتخاب شده دارای پیوندهای پروکسی مختلط هستند", + "Selected only": "فقط انتخاب شده", + "Selected provider": "ارائه‌دهنده انتخاب شده", + "Selecting None will unbind selected connections from proxy pool.": "انتخاب «هیچکدام» پیوند اتصالات انتخاب شده را از استخر پروکسی لغو می‌کند.", + "Send": "ارسال", + "Send to Provider": "ارسال به ارائه‌دهنده", + "Sent to provider as:": "ارسال به ارائه‌دهنده به عنوان:", + "Server": "سرور", + "Server Disconnected": "سرور قطع شد", + "Server off": "سرور خاموش", + "Server running on": "سرور در حال اجرا روی", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "سرویس در ترمینال در حال اجراست. می‌توانید این صفحه وب را ببندید. خاموش کردن، سرویس را متوقف می‌کند.", + "Set Password": "تنظیم رمز عبور", + "Set a new password before accessing the dashboard remotely.": "قبل از دسترسی از راه دور به داشبورد، یک رمز عبور جدید تنظیم کنید.", + "Set password": "تنظیم رمز عبور", + "Setting password for the first time. Leave current password empty or use default:": "تنظیم رمز عبور برای اولین بار. رمز عبور فعلی را خالی بگذارید یا از پیش‌فرض استفاده کنید:", + "Setting up": "در حال راه‌اندازی", + "Settings": "تنظیمات", + "Settings applied successfully!": "تنظیمات با موفقیت اعمال شد!", + "Settings reset successfully!": "تنظیمات با موفقیت بازنشانی شد!", + "Setup": "راه‌اندازی", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "راه‌اندازی + فهرست همه قابلیت‌ها. از اینجا شروع کنید — شامل آدرس پایه، احراز هویت، کشف مدل و پیوند به هر مهارت قابلیت است.", + "Share Endpoint": "اشتراک‌گذاری نقطه پایانی", + "Share URL with team members": "اشتراک‌گذاری URL با اعضای تیم", + "Show": "نمایش", + "Show all": "نمایش همه", + "Show key": "نمایش کلید", + "Show only selected models": "فقط مدل‌های انتخاب شده را نشان دهید", + "Showing": "در حال نمایش", + "Shutdown": "خاموش کردن", + "Sign in with OIDC": "ورود با OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "رابط گفتگوی ساده برای تعامل با هر مدل هوش مصنوعی از ارائه‌دهندگان متصل. یک مدل انتخاب کنید و شروع به گفتگو کنید!", + "Single": "تک", + "Single API endpoint for all major AI providers. Simplify your integration.": "یک نقطه پایانی API برای همه ارائه‌دهندگان اصلی هوش مصنوعی. یکپارچه‌سازی خود را ساده کنید.", + "Some models are not responding": "برخی از مدل‌ها پاسخ نمی‌دهند", + "Sort Codex quotas by remaining": "مرتب‌سازی سهمیه‌های Codex بر اساس باقیمانده", + "Sort accounts by earliest quota reset time": "مرتب‌سازی حساب‌ها بر اساس زودترین زمان بازنشانی سهمیه", + "Source Body": "بدنه منبع", + "Sourcegraph Amp coding assistant CLI": "دستیار کدنویسی Sourcegraph Amp CLI", + "Special reasoning/thinking tokens (fallback to output rate)": "توکن‌های استدلال/تفکر ویژه (بازگشت به نرخ خروجی)", + "Speech To Text": "گفتار به متن", + "Speech-to-Text": "گفتار به متن", + "Standard prompt tokens": "توکن‌های پرامپت استاندارد", + "Start DNS": "راه‌اندازی DNS", + "Start Date": "تاریخ شروع", + "Start Free": "شروع رایگان", + "Start Headroom": "راه‌اندازی Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Headroom را به صورت جداگانه در آدرس پیکربندی شده راه‌اندازی کنید، سپس دوباره بررسی کنید.", + "Start MITM": "راه‌اندازی MITM", + "Start Server": "راه‌اندازی سرور", + "Start Tunnel": "راه‌اندازی تونل", + "Start a conversation": "شروع یک گفتگو", + "Starting 9Router...": "در حال راه‌اندازی 9Router...", + "Status": "وضعیت", + "Status:": "وضعیت:", + "Step 1: Open this URL in your browser": "مرحله ۱: این URL را در مرورگر خود باز کنید", + "Step 2: Paste the callback URL here": "مرحله ۲: URL پاسخ بازگشت را در اینجا بچسبانید", + "Sticky Limit": "محدودیت چسبندگی", + "Sticky:": "چسبنده:", + "Stop": "توقف", + "Stop DNS": "توقف DNS", + "Stop Headroom": "توقف Headroom", + "Stop MITM": "توقف MITM", + "Stop Server": "توقف سرور", + "Stopped": "متوقف شد", + "Strict Proxy": "پروکسی سختگیرانه", + "Style": "سبک", + "Subagent Model": "مدل زیرعامل", + "Sudo Password Required": "رمز عبور sudo الزامی است", + "Sudo password is required": "رمز عبور sudo الزامی است", + "Suggested free models (≥200k context):": "مدل‌های رایگان پیشنهادی (≥۲۰۰k زمینه):", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "مثال‌های میان‌نویس پیشنهادی: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.", + "Support up to 20 active apps & 50 custom domains": "پشتیبانی از حداکثر ۲۰ برنامه فعال و ۵۰ دامنه سفارشی", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "فرمت‌های پشتیبانی شده: protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "همگام‌سازی تنظیمات بین دستگاه‌ها با ذخیره‌سازی اختیاری ابری.", + "System": "سیستم", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "قیف Tailscale", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "قیف Tailscale متوقف خواهد شد. دسترسی از راه دور از طریق URL Tailscale از کار خواهد افتاد.", + "Tailscale installed": "Tailscale نصب شد", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale نصب نشده است. برای فعال‌سازی Funnel آن را نصب کنید.", + "Target Request": "درخواست هدف", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "دما", + "Terminal": "ترمینال", + "Terms of Service": "شرایط خدمات", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "پرامپت سیستم مختصر → ~۶۵٪ توکن خروجی کمتر (تا ۸۷٪)", + "Test": "آزمایش", + "Test Again": "آزمایش مجدد", + "Test All": "آزمایش همه", + "Test Example": "مثال آزمایش", + "Test Results": "نتایج آزمایش", + "Test all API Key connections": "آزمایش همه اتصالات کلید API", + "Test all Compatible connections": "آزمایش همه اتصالات سازگار", + "Test all Free connections": "آزمایش همه اتصالات رایگان", + "Test all Free provider connections": "آزمایش همه اتصالات ارائه‌دهنده رایگان", + "Test all OAuth connections": "آزمایش همه اتصالات OAuth", + "Test connection": "آزمایش اتصال", + "Test model": "آزمایش مدل", + "Test proxy": "آزمایش پروکسی", + "Test proxy URL": "آزمایش آدرس پروکسی", + "Testing...": "در حال آزمایش...", + "Text To Speech": "متن به گفتار", + "Text To Speech combo": "ترکیب متن به گفتار", + "Text to Image": "متن به تصویر", + "Text to Image combo": "ترکیب متن به تصویر", + "Text-to-Speech": "متن به گفتار", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "تولید متن به تصویر از طریق DALL-E، Imagen، FLUX، MiniMax، SDWebUI…", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "تونل Cloudflare قطع خواهد شد. دسترسی از راه دور از طریق URL تونل از کار خواهد افتاد.", + "The proxy server has been stopped.": "سرور پروکسی متوقف شده است.", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "درخواست فوراً توسط OpenAI، Anthropic، Gemini یا دیگران برآورده می‌شود.", + "The tunnel will be disconnected. Remote access will stop working.": "تونل قطع خواهد شد. دسترسی از راه دور از کار خواهد افتاد.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "نقطه پایانی یکپارچه برای تولید هوش مصنوعی. به راحتی ارائه‌دهندگان هوش مصنوعی خود را متصل، مسیردهی و مدیریت کنید.", + "The unified interface for modern AI infrastructure": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی. امن، قابل مشاهده و مقیاس‌پذیر.", + "Theme": "پوسته", + "Thinking": "تفکر", + "Thinking Process": "فرآیند تفکر", + "This is the only time you will see this key. Store it securely.": "این تنها باری است که این کلید را می‌بینید. آن را به‌طور امن ذخیره کنید.", + "This provider is ready to use.": "این ارائه‌دهنده آماده استفاده است.", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "این ارائه‌دهنده آماده استفاده است. در صورت تمایل، درخواست‌ها را از طریق یک استخر پروکسی برای دور زدن محدودیت‌های مبتنی بر IP مسیردهی کنید.", + "This value is write-only after saving.": "این مقدار پس از ذخیره فقط نوشتنی است.", + "Timestamp": "زمان‌سنج", + "Timestamp:": "زمان‌سنج:", + "To get a fresh API key, paste your browser cookie from": "برای دریافت یک کلید API جدید، کوکی مرورگر خود را از", + "Today": "امروز", + "Toggle DNS to redirect": "تغییر وضعیت DNS برای تغییر مسیر", + "Toggle auto-ping": "تغییر وضعیت پینگ خودکار", + "Token Saver": "ذخیره‌ساز توکن", + "Token Types:": "انواع توکن:", + "Token auto-detected from Kiro IDE successfully!": "توکن با موفقیت از Kiro IDE به‌طور خودکار تشخیص داده شد!", + "Token is used once for deployment and not stored.": "توکن فقط یک بار برای استقرار استفاده می‌شود و ذخیره نمی‌شود.", + "Token is used once for deployment, not stored. Found in Organization Settings.": "توکن فقط یک بار برای استقرار استفاده می‌شود، ذخیره نمی‌شود. در تنظیمات سازمان یافت می‌شود.", + "Token will be auto-filled...": "توکن به‌طور خودکار پر می‌شود...", + "Tokens": "توکن‌ها", + "Tokens auto-detected from Cursor IDE successfully!": "توکن‌ها با موفقیت از Cursor IDE به‌طور خودکار تشخیص داده شدند!", + "Tokens used to create cache entries (fallback to input rate)": "توکن‌های استفاده شده برای ایجاد ورودی‌های حافظه پنهان (بازگشت به نرخ ورودی)", + "Tomorrow": "فردا", + "Tool not found or disabled.": "ابزار یافت نشد یا غیرفعال است.", + "Tools": "ابزارها", + "Tools:": "ابزارها:", + "Total Cost": "هزینه کل", + "Total Input Tokens": "کل توکن‌های ورودی", + "Total Models": "تعداد کل مدل‌ها", + "Total Requests": "کل درخواست‌ها", + "Total Tokens": "کل توکن‌ها", + "Total:": "مجموع:", + "Track and manage your API quota limits": "پیگیری و مدیریت محدودیت‌های سهمیه API خود", + "Track token usage, costs, and performance across all providers.": "پیگیری مصرف توکن، هزینه‌ها و عملکرد در همه ارائه‌دهندگان.", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "رونویسی صدا از طریق OpenAI Whisper، Groq، Gemini، Deepgram، AssemblyAI…", + "Transferring data...": "در حال انتقال داده...", + "Translator": "مترجم", + "Translator Debug": "اشکال‌زدایی مترجم", + "Tried in order (top-down) or rotated when round-robin is on.": "به ترتیب امتحان شده (بالا به پایین) یا در صورت روشن بودن چرخشی، چرخش می‌یابد.", + "Trust Cert": "اعتماد به گواهی", + "Trusted": "معتمد", + "Try Again": "دوباره تلاش کنید", + "Tunnel": "تونل", + "Tunnel connected!": "تونل متصل شد!", + "Tunnel disabled": "تونل غیرفعال شد", + "Turn off Empty": "خاموش کردن حساب‌های خالی", + "Turn on Available": "روشن کردن حساب‌های موجود", + "Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری", + "Twitter": "توییتر", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → مارک‌داون / متن / HTML از طریق Firecrawl، Jina، Tavily، Exa.", + "Unavailable": "ناموجود", + "Under": "زیر", + "Unified Endpoint": "نقطه پایانی یکپارچه", + "Unknown": "ناشناخته", + "Unselect all": "لغو انتخاب همه", + "Update": "به‌روزرسانی", + "Update 9Router": "به‌روزرسانی 9Router", + "Update Password": "به‌روزرسانی رمز عبور", + "Update now": "همین حالا به‌روزرسانی کنید", + "Upstream Auth Error": "خطای احراز هویت بالادست", + "Upstream Unavailable": "بالادست در دسترس نیست", + "Usage": "مصرف", + "Usage & Analytics": "مصرف و تحلیل", + "Usage / Limit": "مصرف / محدودیت", + "Usage Logs": "لاگ‌های مصرف", + "Usage Tracking": "پیگیری مصرف", + "Usage by API Key": "مصرف بر اساس کلید API", + "Usage by Account": "مصرف بر اساس حساب", + "Usage by Endpoint": "مصرف بر اساس نقطه پایانی", + "Usage by Model": "مصرف بر اساس مدل", + "Usage:": "مصرف:", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "برای حفظ پایداری نگاشت‌های میان‌نویس Amp در به‌روزرسانی‌های ارائه‌دهنده، از نام‌های مستعار مدل 9Router استفاده کنید.", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استفاده از Antigravity IDE و GitHub Copilot → با هر ارائه‌دهنده/مدلی از 9Router", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "برای ورود به داشبورد از Authentik یا هر ارائه‌دهنده OIDC استفاده کنید.", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "برای ورود به داشبورد از Authentik یا هر ارائه‌دهنده OIDC استفاده کنید. می‌توانید برای داشبورد فقط رمز عبور، فقط OIDC یا هر دو را فعال کنید؛ دسترسی به API مدل همچنان از کلیدهای API استفاده می‌کند.", + "Use a GitLab OAuth application": "از یک برنامه OAuth GitLab استفاده کنید", + "Use a GitLab PAT with api scope": "از یک GitLab PAT با محدوده api استفاده کنید", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "از یک کلید API مستقیم xAI از console.x.ai استفاده کنید. این از Grok Build OAuth جدا است.", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "برای شروع/توقف از یک پروکسی محلی استفاده کنید، یا از یک sidecar خارجی داکر مانند http://headroom:8787.", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "از یک کلید API طولانی‌مدت Kiro/CodeWhisperer (احراز هویت بدون رابط) استفاده کنید.", + "Use in Cursor/Cline": "استفاده در Cursor/Cline", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "از دکمه‌های بالا برای افزودن نقاط پایانی سازگار با OpenAI یا Anthropic استفاده کنید", + "Use your API from any network": "از API خود از هر شبکه‌ای استفاده کنید", + "Valid": "معتبر", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "بردارها برای RAG / جستجوی معنایی از طریق OpenAI، Gemini، Mistral…", + "Vercel API Token": "توکن Vercel API", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel به میلیون‌ها برنامه خدمت می‌کند — ارائه‌دهندگان نمی‌توانند IPهای Vercel را بدون تأثیر بر ترافیک قانونی مسدود کنند", + "Verification URL": "آدرس تأیید", + "Video": "ویدیو", + "View Codex reset credit expiry": "مشاهده انقضای اعتبار بازنشانی Codex", + "View Full Details": "مشاهده جزئیات کامل", + "View on GitHub": "مشاهده در GitHub", + "Visit the URL below and enter the code:": "از URL زیر بازدید کرده و کد را وارد کنید:", + "Visit the login URL below and authorize:": "از URL ورود زیر بازدید کرده و مجوز دهید:", + "Voice": "صدا", + "Voice ID": "شناسه صدا", + "Voyage AI": "Voyage AI", + "Waiting for Authorization": "در انتظار مجوز", + "Waiting for authorization...": "در انتظار مجوز...", + "Warning": "هشدار", + "Web Fetch": "دریافت وب", + "Web Fetch & Search": "جستجو و دریافت وب", + "Web Search": "جستجوی وب", + "Web Search & Fetch (Exa)": "جستجو و دریافت وب (Exa)", + "Welcome": "خوش آمدید", + "What is Cloudflare Relay?": "Cloudflare Relay چیست؟", + "What is Deno Relay?": "Deno Relay چیست؟", + "What is Vercel Relay?": "Vercel Relay چیست؟", + "When": "زمان", + "When ON, dashboard requires password. When OFF, access without login.": "در حالت روشن، داشبورد به رمز عبور نیاز دارد. در حالت خاموش، دسترسی بدون نیاز به ورود.", + "Windows:": "ویندوز:", + "Windows: Run 9Router terminal as Administrator": "ویندوز: ترمینال 9Router را به عنوان مدیر اجرا کنید", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "ویندوز: ترمینال (9Router) را به عنوان مدیر اجرا کنید تا MITM فعال شود", + "Worker Name": "نام Worker", + "Works on any device": "روی هر دستگاهی کار می‌کند", + "Writes to": "نوشته می‌شود به", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "می‌توانید قیمت‌گذاری پیش‌فرض را برای مدل‌های خاص بازنویسی کنید. هر زمان که بخواهید با بازنشانی به پیش‌فرض، نرخ‌های استاندارد را بازیابی کنید.", + "Your": "شما", + "Your Account Name": "نام حساب شما", + "Your Code": "کد شما", + "Your Kiro account via": "حساب Kiro شما از طریق", + "Your OAuth application client ID": "شناسه مشتری برنامه OAuth شما", + "Your organization's AWS IAM Identity Center URL": "URL مرکز هویت AWS IAM سازمان شما", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "درخواست‌های شما از ابزارهای مورد علاقه شما یا SDK یکپارچه ما شروع می‌شود. فقط آدرس پایه را تغییر دهید.", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "درخواست‌های شما از ابزارهای مورد علاقه شما شروع می‌شوند — Cursor، Claude، Copilot یا هر SDK سازگار با OpenAI.", + "account has been connected.": "حساب متصل شده است.", + "active": "فعال", + "add OpenAI/Anthropic compatible endpoints": "افزودن نقاط پایانی سازگار با OpenAI/Anthropic", + "added)": "افزوده شد)", + "again after install.": "دوباره پس از نصب.", + "and click": "و کلیک کنید", + "apiKey": "apiKey", + "below.": "در زیر.", + "bound": "پیوند شده", + "chars)": "کاراکتر)", + "cloudflare relay": "cloudflare relay", + "connection": "اتصال", + "connections": "اتصالات", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "تاریک", + "disabled": "غیرفعال", + "dollars per million tokens": "دلار به ازای هر میلیون توکن", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "مثلاً CwhRBWXzGAHq8TQ4Fs17", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "مثلاً: صدای گرم و ملایم که با لهجه بریتانیایی به‌آرامی صحبت می‌کند", + "e.g. claude-opus-4-5": "مثلاً claude-opus-4-5", + "e.g. my-model-id": "مثلاً my-model-id", + "e.g. tts-1-hd": "مثلاً tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "مثلاً voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "مثلاً، Production API، Dev Environment", + "every request bills all panel models + the judge": "هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند", + "export": "خروجی", + "failed": "ناموفق", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → ۶۰-۹۰٪ توکن ورودی کمتر", + "h ago": "ساعت قبل", + "has been connected.": "متصل شده است.", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "احراز هویت کوکی iFlow", + "import": "وارد کردن", + "inactive": "غیرفعال", + "jcode - Manual Configuration": "jcode - پیکربندی دستی", + "jcode CLI not detected locally": "jcode CLI در سیستم محلی شناسایی نشد", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode یک عامل کدنویسی مبتنی بر Rust با حافظه معنایی، خوشه‌های چندعاملی و عملکرد فوق‌العاده (۲۷.۸ مگابایت رم، ۱۴ میلی‌ثانیه بوت) است.", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "روشن", + "m ago": "دقیقه قبل", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "macOS/Linux:": "macOS/Linux:", + "more": "بیشتر", + "more providers": "ارائه‌دهندگان بیشتر", + "ms / Total": "میلی‌ثانیه / کل", + "name|apiKey": "name|apiKey", + "no_proxy:": "بدون پروکسی:", + "not detected locally": "در سیستم محلی شناسایی نشد", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "باز کردن http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "زمینه اختیاری برای بهبود دقت", + "or VS Code extension marketplace.": "یا بازار افزونه VS Code.", + "or just": "یا فقط", + "passed": "گذرانده شد", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند. بهترین کیفیت، اما هزینه‌برترین: هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند (تماس‌های N+1)", + "records, batches every": "رکوردها، هر دسته", + "requests, max": "درخواست‌ها، حداکثر", + "rotates models across requests to spread load": "مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "s)": "ثانیه)", + "s...": "ثانیه...", + "seconds...": "ثانیه...", + "sends image/PDF/audio requests to a model that supports them first": "درخواست‌های تصویر/PDF/صدا را ابتدا به مدلی که از آنها پشتیبانی می‌کند ارسال می‌کند", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router (پیش‌فرض)", + "system": "سیستم", + "tested": "آزمایش شد", + "the database.": "پایگاه داده.", + "to apply changes": "برای اعمال تغییرات", + "to verify.": "برای تأیید.", + "traffic through 9Router via MITM.": "ترافیک از طریق 9Router از طریق MITM.", + "tries models in order (next on failure)": "مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "unknown": "ناشناخته", + "v1.0 is now live": "v1.0 اکنون زنده است", + "vercel relay": "vercel relay", + "yet.": "هنوز.", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© ۲۰۲۵ 9Router. تمام حقوق محفوظ است.", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند. بهترین کیفیت، اما هزینه‌برترین: هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند (تماس‌های N+1)", + "— rotates models across requests to spread load": "— مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "— sends image/PDF/audio requests to a model that supports them first": "— درخواست‌های تصویر/PDF/صدا را ابتدا به مدلی که از آنها پشتیبانی می‌کند ارسال می‌کند", + "— tries models in order (next on failure)": "— مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ هدف", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ برای ویرایش نگاشت‌های مدل، DNS را فعال کنید", + "⚠️ Local plugins run as subprocess via": "⚠️ افزونه‌های محلی به عنوان زیرفرآیند از طریق اجرا می‌شوند", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ترافیک HTTPS ابزارهای IDE (Antigravity، GitHub Copilot، Kiro) را از طریق CA محلی رهگیری می‌کند تا درخواست‌ها را به ارائه‌دهندگان شما مسیردهی کند. ممکن است شرایط خدمات را نقض کند → مسدود شدن حساب. با مسئولیت خود استفاده کنید.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ اطلاعیه ریسک: این ارائه‌دهنده از اشتراک/جلسه OAuth استفاده می‌کند که به طور رسمی برای استفاده پروکسی/روتر مجوز ندارد. حساب ممکن است محدود یا مسدود شود. با مسئولیت خود استفاده کنید.", + "✓ Confirm Add": "✓ تأیید افزودن", + "📝 Configure providers in dashboard or use environment variables": "📝 ارائه‌دهندگان را در داشبورد پیکربندی کنید یا از متغیرهای محیطی استفاده کنید", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 نیاز به OAuth. اکنون اضافه کنید و پس از اعمال، احراز هویت کنید؛ لیست ابزارها پس از اولین اتصال کشف می‌شود." +} diff --git a/public/i18n/literals/fi.json b/public/i18n/literals/fi.json index dc8b116c..4f86b061 100644 --- a/public/i18n/literals/fi.json +++ b/public/i18n/literals/fi.json @@ -12,6 +12,7 @@ "Logout": "Kirjaudu ulos", "Login": "Kirjaudu sisään", "Providers": "Palveluntarjoajat", + "Style": "Tyyli", "Usage": "Käyttötilastot", "API Key": "API-avain", "Connected": "Yhdistetty", @@ -176,6 +177,7 @@ "How it works:": "Kuinka se toimii:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-pyyntö → DNS-uudelleenohjaus localhost:443:iin → MITM-välityspalvelin sieppaa → 9Router → vastaus Antigravity/Copilot:ille", "No API keys — create one in Keys page": "Ei API-avaimia — luo yksi Keys-sivulla", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "esim.: lämmin, pehmeä ääni, joka puhuu hitaasti brittiaksentilla", "sk_9router (default)": "sk_9router (oletus)", "Server started": "Palvelin käynnistetty", "Failed to start server": "Palvelimen käynnistäminen epäonnistui", diff --git a/public/i18n/literals/fr.json b/public/i18n/literals/fr.json index bbf8854a..55b86581 100644 --- a/public/i18n/literals/fr.json +++ b/public/i18n/literals/fr.json @@ -12,6 +12,7 @@ "Logout": "Déconnexion", "Login": "Connexion", "Providers": "Fournisseurs", + "Style": "Style", "Usage": "Statistiques", "API Key": "Clé API", "Connected": "Connecté", @@ -176,6 +177,7 @@ "How it works:": "Comment ça marche :", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Demande Antigravity/Copilot IDE → Redirection DNS vers localhost:443 → Le proxy MITM intercepte → 9Router → réponse à Antigravity/Copilot", "No API keys — create one in Keys page": "Aucune clé API — créez-en une dans la page Clés", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "ex. : voix chaleureuse et douce, parlant lentement avec un accent britannique", "sk_9router (default)": "sk_9router (par défaut)", "Server started": "Serveur démarré", "Failed to start server": "Impossible de démarrer le serveur", diff --git a/public/i18n/literals/he.json b/public/i18n/literals/he.json index c144d01b..7ed67a5e 100644 --- a/public/i18n/literals/he.json +++ b/public/i18n/literals/he.json @@ -12,6 +12,7 @@ "Logout": "התנתקות", "Login": "כניסה", "Providers": "ספקים", + "Style": "סגנון", "Usage": "סטטיסטיקה", "API Key": "מפתח API", "Connected": "מחובר", @@ -176,6 +177,7 @@ "How it works:": "איך זה עובד:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "בקשת Antigravity/Copilot IDE → הפניה DNS ל-localhost:443 → פרוקסי MITM חוטף → 9Router → תגובה ל-Antigravity/Copilot", "No API keys — create one in Keys page": "אין מפתחות API — צור אחד בעמוד Keys", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "למשל: קול חם ועדין, מדבר לאט במבטא בריטי", "sk_9router (default)": "sk_9router (ברירת מחדל)", "Server started": "השרת התחיל", "Failed to start server": "הפעלת השרת נכשלה", diff --git a/public/i18n/literals/hi.json b/public/i18n/literals/hi.json index 2fc6b338..3f3aeda6 100644 --- a/public/i18n/literals/hi.json +++ b/public/i18n/literals/hi.json @@ -12,6 +12,7 @@ "Logout": "लॉग आउट", "Login": "लॉगिन", "Providers": "प्रदाता", + "Style": "शैली", "Usage": "उपयोग के आंकड़े", "API Key": "API कुंजी", "Connected": "जुड़ा हुआ", @@ -176,6 +177,7 @@ "How it works:": "यह कैसे काम करता है:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE अनुरोध → DNS को localhost:443 में पुनर्निर्देशित करें → MITM प्रॉक्सी इंटरसेप्ट करता है → 9Router → Antigravity/Copilot को प्रतिक्रिया", "No API keys — create one in Keys page": "कोई API कुंजी नहीं — Keys पृष्ठ में एक बनाएं", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "जैसे: गर्म, कोमल आवाज़, ब्रिटिश लहजे में धीरे-धीरे बोलते हुए", "sk_9router (default)": "sk_9router (डिफ़ॉल्ट)", "Server started": "सर्वर शुरू किया गया", "Failed to start server": "सर्वर शुरू करने में विफल", diff --git a/public/i18n/literals/hu.json b/public/i18n/literals/hu.json index 8e339291..927d1135 100644 --- a/public/i18n/literals/hu.json +++ b/public/i18n/literals/hu.json @@ -12,6 +12,7 @@ "Logout": "Kijelentkezés", "Login": "Bejelentkezés", "Providers": "Szolgáltatók", + "Style": "Stílus", "Usage": "Használati statisztika", "API Key": "API-kulcs", "Connected": "Csatlakoztatva", @@ -176,6 +177,7 @@ "How it works:": "Hogyan működik:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE kérés → DNS átirányítás a localhost:443-ra → MITM proxy elfogja → 9Router → válasz Antigravity/Copilot-nak", "No API keys — create one in Keys page": "Nincsenek API-kulcsok — hozzon létre egyet a Keys oldalon", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "pl.: meleg, lágy hang, lassan beszél brit akcentussal", "sk_9router (default)": "sk_9router (alapértelmezett)", "Server started": "Szerver elindult", "Failed to start server": "Nem sikerült elindítani a szervert", diff --git a/public/i18n/literals/id.json b/public/i18n/literals/id.json index 3e5097aa..c60c1397 100644 --- a/public/i18n/literals/id.json +++ b/public/i18n/literals/id.json @@ -12,6 +12,7 @@ "Logout": "Keluar", "Login": "Masuk", "Providers": "Penyedia", + "Style": "Gaya", "Usage": "Statistik Penggunaan", "API Key": "Kunci API", "Connected": "Terhubung", @@ -176,6 +177,7 @@ "How it works:": "Cara kerjanya:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Permintaan Antigravity/Copilot IDE → Pengalihan DNS ke localhost:443 → Proxy MITM mengintersep → 9Router → respons ke Antigravity/Copilot", "No API keys — create one in Keys page": "Tidak ada kunci API — buat satu di halaman Keys", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "mis.: suara hangat dan lembut, berbicara pelan dengan aksen Inggris", "sk_9router (default)": "sk_9router (bawaan)", "Server started": "Server dimulai", "Failed to start server": "Gagal memulai server", diff --git a/public/i18n/literals/it.json b/public/i18n/literals/it.json index 7f684e83..e1619f86 100644 --- a/public/i18n/literals/it.json +++ b/public/i18n/literals/it.json @@ -12,6 +12,7 @@ "Logout": "Esci", "Login": "Accedi", "Providers": "Provider", + "Style": "Stile", "Usage": "Statistiche di utilizzo", "API Key": "Chiave API", "Connected": "Connesso", @@ -176,6 +177,7 @@ "How it works:": "Come funziona:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Richiesta Antigravity/Copilot IDE → Reindirizzamento DNS a localhost:443 → Il proxy MITM intercetta → 9Router → Risposta a Antigravity/Copilot", "No API keys — create one in Keys page": "Nessuna chiave API — crearne una nella pagina Chiavi", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "es.: voce calda e morbida, che parla lentamente con accento britannico", "sk_9router (default)": "sk_9router (predefinito)", "Server started": "Server avviato", "Failed to start server": "Impossibile avviare il server", diff --git a/public/i18n/literals/ja.json b/public/i18n/literals/ja.json index e448c685..bac4f57b 100644 --- a/public/i18n/literals/ja.json +++ b/public/i18n/literals/ja.json @@ -12,6 +12,7 @@ "Logout": "ログアウト", "Login": "ログイン", "Providers": "プロバイダー", + "Style": "スタイル", "Usage": "統計", "API Key": "APIキー", "Connected": "接続済み", @@ -176,6 +177,7 @@ "How it works:": "しくみ:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE リクエスト → localhost:443 への DNS リダイレクト → MITM プロキシが傍受 → 9Router → Antigravity/Copilot への応答", "No API keys — create one in Keys page": "APIキーがありません — キーページで1つ作成してください", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "例:温かく穏やかな声で、イギリス英語のアクセントでゆっくり話す", "sk_9router (default)": "sk_9router(デフォルト)", "Server started": "サーバーが開始されました", "Failed to start server": "サーバーの開始に失敗しました", diff --git a/public/i18n/literals/km.json b/public/i18n/literals/km.json new file mode 100644 index 00000000..bce40e0a --- /dev/null +++ b/public/i18n/literals/km.json @@ -0,0 +1,1398 @@ +{ + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/1M tokens)។ ឧទាហរណ៍៖ អត្រា input 2.50 មានន័យថា $2.50 ក្នុង input tokens ចំនួន 1,000,000", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/1M tokens)។ ឧទាហរណ៍៖ អត្រា Input 2.50 មានន័យថា $2.50 ក្នុង input tokens ចំនួន 1,000,000", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(តាមរយៈការសាកល្បង inference)", + "+ Browse": "+ រុករក", + "+ Combo": "+ Combo", + "+ Custom": "+ ផ្ទាល់ខ្លួន", + "+ Save current as...": "+ រក្សាទុកបច្ចុប្បន្នជា...", + "-compatible models manually or import them from the /models endpoint.": "-compatible ដោយដៃ ឬនាំចូលពួកវាពី Endpoint /models។", + ". Click \"Apply\" to auto-configure.": "។ ចុច \"អនុវត្ត\" ដើម្បីកំណត់រចនាសម្ព័ន្ធដោយស្វ័យប្រវត្តិ។", + "1. CLI & SDKs": "1. CLI និង SDKs", + "1. Client Request (Input)": "1. សំណើរបស់ Client (Input)", + "1. Generates SSL cert & adds to system keychain": "1. បង្កើត SSL certificate ហើយបន្ថែមទៅ system keychain", + "2. 9Router Hub": "2. 9Router Hub", + "2. Provider Request (Translated)": "2. សំណើទៅអ្នកផ្តល់សេវា (បានបកប្រែ)", + "2. Redirects": "2. បញ្ជូនបន្ត", + "24h": "24 ម៉ោង", + "3. AI Providers": "3. អ្នកផ្តល់សេវា AI", + "3. Maps Antigravity models to any provider via 9Router": "3. ផ្គូផ្គងម៉ូដែល Antigravity ទៅអ្នកផ្តល់សេវាណាមួយតាមរយៈ 9Router", + "3. Provider Response (Raw)": "3. ការឆ្លើយតបពីអ្នកផ្តល់សេវា (ដើម)", + "30D": "30 ថ្ងៃ", + "4. Client Response (Final)": "4. ការឆ្លើយតបទៅ Client (ចុងក្រោយ)", + "60D": "60 ថ្ងៃ", + "7D": "7 ថ្ងៃ", + "9Router (Entry)": "9Router (ច្រកចូល)", + "9Router Base URL": "9Router Base URL", + ": Account | Workers Scripts | Edit": "៖ គណនី | Workers Scripts | កែសម្រួល", + ": Include | Account |": "៖ រួមបញ្ចូល | គណនី |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "Proxy សម្រាប់ AI Endpoint ជាមួយផ្ទាំងគ្រប់គ្រងលើវេប — ជាកំណែ JavaScript របស់ CLIProxyAPI។ ដំណើរការយ៉ាងរលូនជាមួយ Claude Code, OpenAI Codex, Cline, RooCode និងឧបករណ៍ CLI ផ្សេងទៀត។", + "API Endpoint": "API Endpoint", + "API Key": "API Key", + "API Key (for Check)": "API Key (សម្រាប់ពិនិត្យ)", + "API Key Compatible Providers": "អ្នកផ្តល់សេវាដែលត្រូវគ្នាជាមួយ API Key", + "API Key Created": "បានបង្កើត API Key", + "API Key Name": "ឈ្មោះ API Key", + "API Key Providers": "អ្នកផ្តល់សេវា API Key", + "API Keys": "API Keys", + "API Reference": "ឯកសារយោង API", + "API Token": "API Token", + "API Tokens": "API Tokens", + "API Type": "ប្រភេទ API", + "API Version": "កំណែ API", + "API endpoint configuration": "ការកំណត់រចនាសម្ព័ន្ធ API Endpoint", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "AWS Region", + "AWS region for the key (default: us-east-1)": "AWS region សម្រាប់ Key (លំនាំដើម៖ us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "AWS region សម្រាប់ Identity Center របស់អ្នក (លំនាំដើម៖ us-east-1)", + "About": "អំពី", + "Access Anywhere": "ចូលប្រើពីគ្រប់ទីកន្លែង", + "Access Token": "Access Token", + "Access token will be auto-filled...": "Access Token នឹងត្រូវបានបំពេញដោយស្វ័យប្រវត្តិ...", + "Access your terminal, desktop & files from anywhere": "ចូលប្រើ terminal, desktop និងឯកសាររបស់អ្នកពីគ្រប់ទីកន្លែង", + "Account": "គណនី", + "Account ID": "ID គណនី", + "Account Resources": "ធនធានគណនី", + "Accounts per page": "ចំនួនគណនីក្នុងមួយទំព័រ", + "Action": "សកម្មភាព", + "Activate": "ធ្វើឱ្យសកម្ម", + "Active": "សកម្ម", + "Active All": "ធ្វើឱ្យសកម្មទាំងអស់", + "Active:": "សកម្ម៖", + "Add": "បន្ថែម", + "Add API Key": "បន្ថែម API Key", + "Add Anthropic Compatible": "បន្ថែម Anthropic Compatible", + "Add Connection": "បន្ថែមការតភ្ជាប់", + "Add Custom Embedding": "បន្ថែម Embedding ផ្ទាល់ខ្លួន", + "Add Custom MCP": "បន្ថែម MCP ផ្ទាល់ខ្លួន", + "Add Custom Model": "បន្ថែមម៉ូដែលផ្ទាល់ខ្លួន", + "Add Model": "បន្ថែមម៉ូដែល", + "Add Model Config": "បន្ថែមការកំណត់រចនាសម្ព័ន្ធម៉ូដែល", + "Add Model for GitHub Copilot": "បន្ថែមម៉ូដែលសម្រាប់ GitHub Copilot", + "Add Model for OpenCode": "បន្ថែមម៉ូដែលសម្រាប់ OpenCode", + "Add Model to Combo": "បន្ថែមម៉ូដែលទៅ Combo", + "Add New Provider": "បន្ថែមអ្នកផ្តល់សេវាថ្មី", + "Add OpenAI Compatible": "បន្ថែម OpenAI Compatible", + "Add Provider": "បន្ថែមអ្នកផ្តល់សេវា", + "Add Proxy Pool": "បន្ថែម Proxy Pool", + "Add Shorthands": "បន្ថែមឈ្មោះកាត់", + "Add a connection to enable importing models.": "បន្ថែមការតភ្ជាប់ ដើម្បីអនុញ្ញាតឱ្យនាំចូលម៉ូដែល។", + "Add connection using browser cookie": "បន្ថែមការតភ្ជាប់ដោយប្រើ browser cookie", + "Add model": "បន្ថែមម៉ូដែល", + "Add server": "បន្ថែមម៉ាស៊ីនមេ", + "Add the following configuration to your models array:": "បន្ថែមការកំណត់រចនាសម្ព័ន្ធខាងក្រោមទៅក្នុង models array របស់អ្នក៖", + "Add your first connection to get started": "បន្ថែមការតភ្ជាប់ដំបូងរបស់អ្នកដើម្បីចាប់ផ្តើម", + "Administrator required": "ត្រូវការសិទ្ធិ Administrator", + "Administrator required — restart 9Router as Administrator to use MITM": "ត្រូវការសិទ្ធិ Administrator — ចាប់ផ្តើម 9Router ឡើងវិញជា Administrator ដើម្បីប្រើ MITM", + "After authorization, copy the full URL from your browser address bar.": "បន្ទាប់ពីផ្តល់សិទ្ធិ សូមចម្លង URL ពេញពីរបារអាសយដ្ឋានរបស់ browser។", + "After authorization, copy the full URL from your browser.": "បន្ទាប់ពីផ្តល់សិទ្ធិ សូមចម្លង URL ពេញពី browser របស់អ្នក។", + "After installation, run": "បន្ទាប់ពីដំឡើង សូមដំណើរការ", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "បន្ទាប់ពីចូល អ្នកត្រូវចម្លង callback URL ពី browser ហើយបិទភ្ជាប់វានៅទីនេះ។", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "Alibaba Qwen Code CLI — គាំទ្រអ្នកផ្តល់សេវា OpenAI, Anthropic និង Gemini តាមរយៈ 9Router", + "All": "ទាំងអស់", + "All AI Providers": "អ្នកផ្តល់សេវា AI ទាំងអស់", + "All Providers": "អ្នកផ្តល់សេវាទាំងអស់", + "All models are responding normally.": "ម៉ូដែលទាំងអស់កំពុងឆ្លើយតបជាធម្មតា។", + "All providers": "អ្នកផ្តល់សេវាទាំងអស់", + "All rates are in": "អត្រាទាំងអស់គិតជា", + "All selected currently unbound": "ធាតុដែលបានជ្រើសទាំងអស់បច្ចុប្បន្នមិនទាន់បានភ្ជាប់", + "Allow dashboard access via tunnel": "អនុញ្ញាតឱ្យចូលប្រើផ្ទាំងគ្រប់គ្រងតាមរយៈ Tunnel", + "Allow either password or OIDC.": "អនុញ្ញាតទាំងពាក្យសម្ងាត់ ឬ OIDC។", + "An error occurred": "មានកំហុសកើតឡើង", + "An error occurred. Please try again.": "មានកំហុសកើតឡើង។ សូមព្យាយាមម្តងទៀត។", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "Anthropic Compatible (Production)", + "Anthropic Compatible Details": "ព័ត៌មានលម្អិត Anthropic Compatible", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "សំណើ Antigravity/Copilot IDE → DNS បញ្ជូនបន្តទៅ localhost:443 → MITM Proxy ចាប់សំណើ → 9Router → ការឆ្លើយតបទៅ Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "អាចប្រើម៉ូដែលណាមួយដែលមានក្នុង 9Router — មិនមែនតែម៉ូដែល Qwen ទេ។ ជ្រើសរើសពី Qwen, Claude, Gemini, GPT និងផ្សេងទៀត។", + "App Name": "ឈ្មោះ App", + "Apply": "អនុវត្ត", + "Apply Proxy": "អនុវត្ត Proxy", + "Applying...": "កំពុងអនុវត្ត...", + "Are you sure you want to close the proxy server?": "តើអ្នកប្រាកដថាចង់បិទម៉ាស៊ីនមេ Proxy មែនទេ?", + "Are you sure you want to disable the tunnel?": "តើអ្នកប្រាកដថាចង់បិទ Tunnel មែនទេ?", + "Attempting to reconnect...": "កំពុងព្យាយាមតភ្ជាប់ឡើងវិញ...", + "Audio File": "ឯកសារសំឡេង", + "Auth Mode": "របៀបផ្ទៀងផ្ទាត់", + "Authenticate": "ផ្ទៀងផ្ទាត់", + "Authentication Method": "វិធីសាស្ត្រផ្ទៀងផ្ទាត់", + "Authentication Successful": "ការផ្ទៀងផ្ទាត់បានជោគជ័យ", + "Authentication Successful!": "ការផ្ទៀងផ្ទាត់បានជោគជ័យ!", + "Authless": "មិនត្រូវការការផ្ទៀងផ្ទាត់", + "Authorization Successful!": "ការផ្តល់សិទ្ធិបានជោគជ័យ!", + "Authorize": "ផ្តល់សិទ្ធិ", + "Auto (by priority)": "ស្វ័យប្រវត្តិ (តាមអាទិភាព)", + "Auto Refresh (3s)": "ផ្ទុកឡើងវិញដោយស្វ័យប្រវត្តិ (3 វិនាទី)", + "Auto-detect": "រកឃើញដោយស្វ័យប្រវត្តិ", + "Auto-detecting token...": "កំពុងរក Token ដោយស្វ័យប្រវត្តិ...", + "Auto-detecting tokens...": "កំពុងរក Tokens ដោយស្វ័យប្រវត្តិ...", + "Auto-ping": "Ping ស្វ័យប្រវត្តិ", + "Auto-refresh": "ផ្ទុកឡើងវិញដោយស្វ័យប្រវត្តិ", + "Auto:": "ស្វ័យប្រវត្តិ៖", + "Automatically switch between providers when limits are hit.": "ប្តូររវាងអ្នកផ្តល់សេវាដោយស្វ័យប្រវត្តិ នៅពេលឈានដល់កម្រិតកំណត់។", + "Available": "មាន", + "Available Models": "ម៉ូដែលដែលមាន", + "Azure Endpoint": "Azure Endpoint", + "Azure OpenAI Configuration": "ការកំណត់រចនាសម្ព័ន្ធ Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "ត្រឡប់ក្រោយ", + "Back to CLI Tools": "ត្រឡប់ទៅឧបករណ៍ CLI", + "Back to Providers": "ត្រឡប់ទៅអ្នកផ្តល់សេវា", + "Base URL": "Base URL", + "Batch Import": "នាំចូលជាបាច់", + "Batch Import Proxies": "នាំចូល Proxies ជាបាច់", + "Batch Size": "ទំហំបាច់", + "Beautiful web dashboard for managing providers and monitoring usage.": "ផ្ទាំងគ្រប់គ្រងលើវេបដ៏ស្រស់ស្អាត សម្រាប់គ្រប់គ្រងអ្នកផ្តល់សេវា និងតាមដានការប្រើប្រាស់។", + "Best quality, but costs the most": "គុណភាពល្អបំផុត ប៉ុន្តែចំណាយខ្ពស់បំផុត", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "ណែនាំម៉ូដែលឱ្យប្រើកូដតិចបំផុត៖ YAGNI, ប្រើ stdlib ឡើងវិញ និងលុបជំនួសការបន្ថែម", + "Binary File": "ឯកសារ Binary", + "Blog": "ប្លុក", + "Both": "ទាំងពីរ", + "Browse & edit files": "រុករក និងកែសម្រួលឯកសារ", + "Browse MCP Marketplace": "រុករក MCP Marketplace", + "Browse source, README, and examples.": "រុករក source, README និងឧទាហរណ៍។", + "Browser Control (Browser MCP)": "ការគ្រប់គ្រង Browser (Browser MCP)", + "Bulk Add": "បន្ថែមជាច្រើន", + "CLI Support": "ការគាំទ្រ CLI", + "CLI Tools": "ឧបករណ៍ CLI", + "CLI on the host →": "CLI នៅលើ host →", + "CLIProxyAPI Auth JSON": "CLIProxyAPI Auth JSON", + "Cache Creation": "ការបង្កើត Cache", + "Cache Creation:": "ការបង្កើត Cache៖", + "Cached": "បាន Cache", + "Cached Tokens": "Tokens ដែលបាន Cache", + "Cached Tokens:": "Tokens ដែលបាន Cache៖", + "Cached input tokens (typically 50% of input rate)": "Input Tokens ដែលបាន Cache (ជាទូទៅ 50% នៃអត្រា input)", + "Cached:": "បាន Cache៖", + "Calls per account before switching": "ចំនួន Call ក្នុងមួយគណនី មុនពេលប្តូរ", + "Calls per combo model before switching": "ចំនួន Call ក្នុងមួយម៉ូដែល Combo មុនពេលប្តូរ", + "Cancel": "បោះបង់", + "Capacity auto-switch": "ប្តូរដោយស្វ័យប្រវត្តិតាមសមត្ថភាព", + "Cert": "Certificate", + "Change Log": "កំណត់ហេតុផ្លាស់ប្តូរ", + "Changelog": "កំណត់ហេតុផ្លាស់ប្តូរ", + "Chat": "ជជែក", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "ជជែក / បង្កើតកូដតាមទម្រង់ OpenAI ឬ Anthropic ជាមួយ streaming។", + "Chat Completions": "Chat Completions", + "Check": "ពិនិត្យ", + "Checking Claude CLI...": "កំពុងពិនិត្យ Claude CLI...", + "Checking Claude Cowork...": "កំពុងពិនិត្យ Claude Cowork...", + "Checking Cline...": "កំពុងពិនិត្យ Cline...", + "Checking Codex CLI...": "កំពុងពិនិត្យ Codex CLI...", + "Checking Copilot config...": "កំពុងពិនិត្យការកំណត់រចនាសម្ព័ន្ធ Copilot...", + "Checking DeepSeek TUI...": "កំពុងពិនិត្យ DeepSeek TUI...", + "Checking Factory Droid CLI...": "កំពុងពិនិត្យ Factory Droid CLI...", + "Checking Hermes Agent...": "កំពុងពិនិត្យ Hermes Agent...", + "Checking Kilo Code...": "កំពុងពិនិត្យ Kilo Code...", + "Checking Open Claw CLI...": "កំពុងពិនិត្យ Open Claw CLI...", + "Checking OpenCode CLI...": "កំពុងពិនិត្យ OpenCode CLI...", + "Checking jcode CLI...": "កំពុងពិនិត្យ jcode CLI...", + "Checking...": "កំពុងពិនិត្យ...", + "Choose API Provider → Ollama": "ជ្រើសរើសអ្នកផ្តល់សេវា API → Ollama", + "Choose how to authenticate with GitLab Duo:": "ជ្រើសរើសវិធីផ្ទៀងផ្ទាត់ជាមួយ GitLab Duo៖", + "Choose your authentication method:": "ជ្រើសរើសវិធីសាស្ត្រផ្ទៀងផ្ទាត់របស់អ្នក៖", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Claude CLI not detected locally": "រកមិនឃើញ Claude CLI នៅលើម៉ាស៊ីន", + "Claude CLI not installed": "មិនទាន់បានដំឡើង Claude CLI", + "Claude Cowork - Manual Configuration": "Claude Cowork - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Claude Desktop (Cowork mode) not detected": "រកមិនឃើញ Claude Desktop (របៀប Cowork)", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork (inference ពីភាគីទីបី)", + "Clear": "សម្អាត", + "Clear (will use main model)": "សម្អាត (នឹងប្រើម៉ូដែលមេ)", + "Clear Filters": "សម្អាតតម្រង", + "Clear search": "សម្អាតការស្វែងរក", + "Click": "ចុច", + "Click \"View All Model\" → \"Add Custom Model\"": "ចុច \"មើលម៉ូដែលទាំងអស់\" → \"បន្ថែមម៉ូដែលផ្ទាល់ខ្លួន\"", + "Click a model to set/clear active": "ចុចម៉ូដែលដើម្បីកំណត់/ដកស្ថានភាពសកម្ម", + "Click to add, click again to remove. Changes are saved automatically.": "ចុចដើម្បីបន្ថែម ហើយចុចម្តងទៀតដើម្បីដកចេញ។ ការផ្លាស់ប្តូរត្រូវបានរក្សាទុកដោយស្វ័យប្រវត្តិ។", + "Click to edit": "ចុចដើម្បីកែសម្រួល", + "Click to retry": "ចុចដើម្បីព្យាយាមម្តងទៀត", + "Client ID": "Client ID", + "Client Request": "សំណើរបស់ Client", + "Client Response": "ការឆ្លើយតបទៅ Client", + "Client Secret": "Client Secret", + "Cline - Manual Configuration": "Cline - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Cline AI Coding Assistant": "ជំនួយការសរសេរកូដ AI Cline", + "Cline not detected locally": "រកមិនឃើញ Cline នៅលើម៉ាស៊ីន", + "Close": "បិទ", + "Close Proxy": "បិទ Proxy", + "Close provider filter": "បិទតម្រងអ្នកផ្តល់សេវា", + "Close reset credit expiry modal": "បិទផ្ទាំងកំណត់ថ្ងៃផុតកំណត់ Credit ឡើងវិញ", + "Close test results": "បិទលទ្ធផលសាកល្បង", + "Closing in": "នឹងបិទក្នុង", + "Cloud Sync": "សមកាលកម្ម Cloud", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Cloudflare Tunnel", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Codex CLI not detected locally": "រកមិនឃើញ Codex CLI នៅលើម៉ាស៊ីន", + "Codex CLI not installed": "មិនទាន់បានដំឡើង Codex CLI", + "Codex Reset Credit Expiry": "កំណត់ថ្ងៃផុតកំណត់ Credit របស់ Codex ឡើងវិញ", + "Codex uses": "Codex ប្រើ", + "Combo Name": "ឈ្មោះ Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Combo Sticky Limit", + "Combos": "Combos", + "Coming soon...": "នឹងមកដល់ឆាប់ៗ...", + "Comma-separated hostnames/domains to bypass the proxy.": "Hostnames/domains បំបែកដោយសញ្ញាក្បៀស សម្រាប់រំលង Proxy។", + "Comma-separated hosts/domains to bypass proxy": "Hosts/domains បំបែកដោយសញ្ញាក្បៀស សម្រាប់រំលង Proxy", + "Company": "ក្រុមហ៊ុន", + "Complete the authorization in the popup window.": "បញ្ចប់ការផ្តល់សិទ្ធិនៅក្នុងផ្ទាំង popup។", + "Completion/response tokens": "Tokens សម្រាប់ Completion/response", + "Compress LLM output": "បង្រួម Output របស់ LLM", + "Compress context": "បង្រួម Context", + "Compress prompts via /v1/compress before routing to the model": "បង្រួម Prompts តាម /v1/compress មុនពេលបញ្ជូនទៅម៉ូដែល", + "Compress tool output": "បង្រួម Output របស់ឧបករណ៍", + "Compress tool output to reduce token usage.": "បង្រួម Output របស់ឧបករណ៍ ដើម្បីកាត់បន្ថយការប្រើ Token។", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "ទីតាំង Config៖ Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "ទីតាំង Config៖ Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "ការកំណត់រចនាសម្ព័ន្ធ", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "កំណត់ 9router ជាអ្នកផ្តល់សេវាដែលត្រូវគ្នាជាមួយ OpenAI ដើម្បីបញ្ជូនសំណើ jcode ទាំងអស់តាមស្រទាប់បង្កើនប្រសិទ្ធភាពរបស់ 9router។", + "Configure CLI tools": "កំណត់រចនាសម្ព័ន្ធឧបករណ៍ CLI", + "Configure a new AI provider to use with your applications.": "កំណត់រចនាសម្ព័ន្ធអ្នកផ្តល់សេវា AI ថ្មី ដើម្បីប្រើជាមួយកម្មវិធីរបស់អ្នក។", + "Configure pricing rates for cost tracking and calculations": "កំណត់អត្រាតម្លៃសម្រាប់តាមដាន និងគណនាចំណាយ", + "Configure providers and API keys via web interface": "កំណត់អ្នកផ្តល់សេវា និង API Keys តាមរយៈផ្ទៃប្រើប្រាស់លើវេប", + "Configured": "បានកំណត់រចនាសម្ព័ន្ធ", + "Confirm": "បញ្ជាក់", + "Confirm New Password": "បញ្ជាក់ពាក្យសម្ងាត់ថ្មី", + "Confirm Password": "បញ្ជាក់ពាក្យសម្ងាត់", + "Confirm new password": "បញ្ជាក់ពាក្យសម្ងាត់ថ្មី", + "Connect": "ភ្ជាប់", + "Connect AI tools remotely": "ភ្ជាប់ឧបករណ៍ AI ពីចម្ងាយ", + "Connect Cursor IDE": "ភ្ជាប់ Cursor IDE", + "Connect GitLab Duo": "ភ្ជាប់ GitLab Duo", + "Connect Kiro": "ភ្ជាប់ Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "ភ្ជាប់ទៅអ្នកផ្តល់សេវាដោយ OAuth ដើម្បីតាមដានកម្រិត API quota និងការប្រើប្រាស់របស់អ្នក។", + "Connect via OAuth or API keys. Securely manage credentials.": "ភ្ជាប់តាម OAuth ឬ API Keys។ គ្រប់គ្រងព័ត៌មានសម្ងាត់ដោយសុវត្ថិភាព។", + "Connect with OAuth2": "ភ្ជាប់ដោយ OAuth2", + "Connect your account using OAuth2 authentication.": "ភ្ជាប់គណនីរបស់អ្នកដោយប្រើការផ្ទៀងផ្ទាត់ OAuth2។", + "Connected": "បានភ្ជាប់", + "Connected Successfully!": "បានភ្ជាប់ដោយជោគជ័យ!", + "Connected providers only": "តែអ្នកផ្តល់សេវាដែលបានភ្ជាប់", + "Connecting...": "កំពុងភ្ជាប់...", + "Connection": "ការតភ្ជាប់", + "Connection Details": "ព័ត៌មានលម្អិតការតភ្ជាប់", + "Connection Failed": "ការតភ្ជាប់បានបរាជ័យ", + "Connections": "ការតភ្ជាប់", + "Console Log": "កំណត់ហេតុ Console", + "Contact": "ទំនាក់ទំនង", + "Content": "ខ្លឹមសារ", + "Continue": "បន្ត", + "Continue AI Assistant": "ជំនួយការ AI Continue", + "Continue to summary": "បន្តទៅសេចក្តីសង្ខេប", + "Continue with GitHub": "បន្តជាមួយ GitHub", + "Continue with Google": "បន្តជាមួយ Google", + "Cookie": "Cookie", + "Cookie Auth": "ការផ្ទៀងផ្ទាត់ដោយ Cookie", + "Cookie String": "Cookie String", + "Cooldown": "រយៈពេលរង់ចាំ", + "Copied!": "បានចម្លង!", + "Copy": "ចម្លង", + "Copy & Shutdown": "ចម្លង និងបិទ", + "Copy This URL": "ចម្លង URL នេះ", + "Copy a link and paste to your AI to use 9Router — no install needed": "ចម្លង Link ហើយបិទភ្ជាប់ទៅ AI របស់អ្នក ដើម្បីប្រើ 9Router — មិនចាំបាច់ដំឡើង", + "Copy combo name": "ចម្លងឈ្មោះ Combo", + "Copy install command": "ចម្លង Command ដំឡើង", + "Copy model": "ចម្លងម៉ូដែល", + "Copy the JSON below to your ~/.qwen/settings.json file.": "ចម្លង JSON ខាងក្រោមទៅឯកសារ ~/.qwen/settings.json របស់អ្នក។", + "Copy the entire cookie string (must include BXAuth)": "ចម្លង Cookie String ទាំងមូល (ត្រូវមាន BXAuth)", + "Cost": "ចំណាយ", + "Cost Calculation:": "ការគណនាចំណាយ៖", + "Costs": "ចំណាយ", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "ចំណាយត្រូវបានគណនាតាមការប្រើ Token និងអត្រាតម្លៃ។ ចំណាយនៃសំណើនីមួយៗកំណត់ដោយ៖ (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", + "Could not read Cursor database automatically.": "មិនអាចអានមូលដ្ឋានទិន្នន័យ Cursor ដោយស្វ័យប្រវត្តិបានទេ។", + "Create": "បង្កើត", + "Create API Key": "បង្កើត API Key", + "Create Combo": "បង្កើត Combo", + "Create Cowork Combo": "បង្កើត Cowork Combo", + "Create Key": "បង្កើត Key", + "Create Provider": "បង្កើតអ្នកផ្តល់សេវា", + "Create Token": "បង្កើត Token", + "Create a": "បង្កើត", + "Create a proxy pool entry, then assign it to connections.": "បង្កើតធាតុ Proxy Pool បន្ទាប់មកកំណត់វាទៅការតភ្ជាប់។", + "Create model combos with fallback support": "បង្កើត Combo ម៉ូដែលដែលគាំទ្រ Fallback", + "Create your first API key to get started": "បង្កើត API Key ដំបូងរបស់អ្នកដើម្បីចាប់ផ្តើម", + "Created": "បានបង្កើត", + "Creating...": "កំពុងបង្កើត...", + "Current": "បច្ចុប្បន្ន", + "Current Password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Current Pricing Overview": "ទិដ្ឋភាពទូទៅនៃតម្លៃបច្ចុប្បន្ន", + "Current password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Current: Keeps": "បច្ចុប្បន្ន៖ រក្សា", + "Currently using accounts in priority order (Fill First).": "បច្ចុប្បន្នកំពុងប្រើគណនីតាមលំដាប់អាទិភាព (Fill First)។", + "Cursor AI Code Editor": "កម្មវិធីកែកូដ AI Cursor", + "Cursor IDE not detected. Please paste your tokens manually.": "រកមិនឃើញ Cursor IDE។ សូមបិទភ្ជាប់ Tokens របស់អ្នកដោយដៃ។", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor បញ្ជូនសំណើតាមម៉ាស៊ីនមេរបស់ខ្លួន ដូច្នេះ Local Endpoint មិនត្រូវបានគាំទ្រទេ។ សូមបើក Tunnel ឬ Cloud Endpoint ក្នុងការកំណត់។", + "Custom": "ផ្ទាល់ខ្លួន", + "Custom Pricing:": "តម្លៃផ្ទាល់ខ្លួន៖", + "Custom Providers (OpenAI/Anthropic Compatible)": "អ្នកផ្តល់សេវាផ្ទាល់ខ្លួន (OpenAI/Anthropic Compatible)", + "Custom Token": "Token ផ្ទាល់ខ្លួន", + "Custom accounts per page": "ចំនួនគណនីផ្ទាល់ខ្លួនក្នុងមួយទំព័រ", + "Custom providers": "អ្នកផ្តល់សេវាផ្ទាល់ខ្លួន", + "Custom...": "ផ្ទាល់ខ្លួន...", + "Cycle through accounts to distribute load": "ប្តូរវេនគណនីដើម្បីចែកចាយបន្ទុក", + "Cycle through providers in combos instead of always starting with first": "ប្តូរវេនអ្នកផ្តល់សេវាក្នុង Combos ជំនួសឱ្យការចាប់ផ្តើមពីអ្នកទីមួយជានិច្ច", + "DNS off": "DNS បិទ", + "Dashboard": "ផ្ទាំងគ្រប់គ្រង", + "Dashboard Password": "ពាក្យសម្ងាត់ផ្ទាំងគ្រប់គ្រង", + "Dashboard:": "ផ្ទាំងគ្រប់គ្រង៖", + "Data Location:": "ទីតាំងទិន្នន័យ៖", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "ទិន្នន័យហូរយ៉ាងរលូនពីកម្មវិធីរបស់អ្នក តាមស្រទាប់បញ្ជូនផ្លូវឆ្លាតវៃ ទៅអ្នកផ្តល់សេវាដែលសមស្របបំផុតសម្រាប់ការងារ។", + "Data flows seamlessly through our intelligent routing system": "ទិន្នន័យហូរយ៉ាងរលូនតាមប្រព័ន្ធបញ្ជូនផ្លូវឆ្លាតវៃរបស់យើង", + "Database Location": "ទីតាំងមូលដ្ឋានទិន្នន័យ", + "Database backup downloaded": "បានទាញយកការបម្រុងទុកមូលដ្ឋានទិន្នន័យ", + "Database imported successfully": "បាននាំចូលមូលដ្ឋានទិន្នន័យដោយជោគជ័យ", + "DateTime": "កាលបរិច្ឆេទ និងពេលវេលា", + "Deactivate": "ធ្វើឱ្យអសកម្ម", + "Debug": "បំបាត់កំហុស", + "Debug translation flow between formats": "បំបាត់កំហុសក្នុងលំហូរបកប្រែរវាងទម្រង់", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "DeepSeek TUI not detected locally": "រកមិនឃើញ DeepSeek TUI នៅលើម៉ាស៊ីន", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI ប្រើ ~/.deepseek/config.toml សម្រាប់ការកំណត់រចនាសម្ព័ន្ធ។ 9Router នឹងកែអ្នកផ្តល់សេវាទៅរបៀប 'openai' ជាមួយ base_url, api_key និង model របស់អ្នក។", + "DeepSeek Terminal Coding Agent (Rust TUI)": "DeepSeek Coding Agent សម្រាប់ Terminal (Rust TUI)", + "Default Model": "ម៉ូដែលលំនាំដើម", + "Default password is": "ពាក្យសម្ងាត់លំនាំដើមគឺ", + "Default password is 123456": "ពាក្យសម្ងាត់លំនាំដើមគឺ 123456", + "Delete": "លុប", + "Delete API Key": "លុប API Key", + "Delete connection": "លុបការតភ្ជាប់", + "Delete saved endpoint": "លុប Endpoint ដែលបានរក្សាទុក", + "Delete selected preset": "លុប Preset ដែលបានជ្រើស", + "Delete this combo?": "លុប Combo នេះមែនទេ?", + "Delete this connection?": "លុបការតភ្ជាប់នេះមែនទេ?", + "Deno Deploy API Token": "Deno Deploy API Token", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 ដំណើរការលើបណ្តាញ Edge សកលដែលមានប្រសិទ្ធភាពខ្ពស់", + "Deno Relay": "Deno Relay", + "Deploy": "ដាក់ឱ្យដំណើរការ", + "Deploy Cloudflare Relay": "ដាក់ Cloudflare Relay ឱ្យដំណើរការ", + "Deploy Deno Relay": "ដាក់ Deno Relay ឱ្យដំណើរការ", + "Deploy Relay": "ដាក់ Relay ឱ្យដំណើរការ", + "Deploy Vercel Relay": "ដាក់ Vercel Relay ឱ្យដំណើរការ", + "Deploy multiple relays for maximum IP diversity": "ដាក់ Relays ច្រើនឱ្យដំណើរការ ដើម្បីទទួលបាន IP ចម្រុះអតិបរមា", + "Deploy multiple relays on different accounts for more IP diversity": "ដាក់ Relays ច្រើនលើគណនីផ្សេងៗ ដើម្បីទទួលបាន IP ចម្រុះកាន់តែច្រើន", + "Deploying... (may take ~1 min)": "កំពុងដាក់ឱ្យដំណើរការ... (អាចចំណាយពេលប្រហែល 1 នាទី)", + "Deployment Name": "ឈ្មោះ Deployment", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "ដាក់ Cloudflare Worker ជា Proxy Relay ឱ្យដំណើរការ។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់នឹងត្រូវបញ្ជូនតាមបណ្តាញ Edge សកលរបស់ Cloudflare។", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "ដាក់ Relay Worker ទៅបណ្តាញ Edge សកលរបស់ Deno Deploy។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់ត្រូវបានបញ្ជូនតាម Edge របស់ Deno ដើម្បីលាក់ IP ពិតរបស់អ្នក។", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "ដាក់មុខងារ Edge Relay ទៅ Vercel ដែលបញ្ជូនសំណើជា Proxy តាមបណ្តាញរបស់ Vercel។", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "ដាក់មុខងារ Edge Relay ទៅ Vercel។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់នឹងត្រូវបញ្ជូនតាមបណ្តាញ Edge របស់ Vercel ដើម្បីលាក់ IP ពិតរបស់អ្នកពីអ្នកផ្តល់សេវា។", + "Desktop": "ផ្ទៃតុ", + "Detail": "ព័ត៌មានលម្អិត", + "Details": "ព័ត៌មានលម្អិត", + "Dimensions": "វិមាត្រ", + "Disable": "បិទ", + "Disable All": "បិទទាំងអស់", + "Disable Tailscale": "បិទ Tailscale", + "Disable Tunnel": "បិទ Tunnel", + "Disable connections with depleted quota on the current page": "បិទការតភ្ជាប់ដែលអស់ Quota នៅលើទំព័របច្ចុប្បន្ន", + "Disable provider": "បិទអ្នកផ្តល់សេវា", + "Disable this model": "បិទម៉ូដែលនេះ", + "Disabled": "បានបិទ", + "Disabling...": "កំពុងបិទ...", + "Disconnected from server": "បានផ្តាច់ពីម៉ាស៊ីនមេ", + "Dismiss notification": "បិទការជូនដំណឹង", + "Display Name": "ឈ្មោះបង្ហាញ", + "Display language": "ភាសាបង្ហាញ", + "Docs": "ឯកសារ", + "Documentation": "ឯកសារ", + "Domain:": "Domain៖", + "Donate": "បរិច្ចាគ", + "Done": "រួចរាល់", + "Download": "ទាញយក", + "Download Backup": "ទាញយកការបម្រុងទុក", + "Drag to reorder": "អូសដើម្បីរៀបលំដាប់ឡើងវិញ", + "Easy Setup": "ការរៀបចំងាយស្រួល", + "Edit": "កែសម្រួល", + "Edit Combo": "កែសម្រួល Combo", + "Edit Connection": "កែសម្រួលការតភ្ជាប់", + "Edit Pricing": "កែសម្រួលតម្លៃ", + "Edit Proxy Pool": "កែសម្រួល Proxy Pool", + "Edit connection": "កែសម្រួលការតភ្ជាប់", + "Edit hosts file manually to add the following entries:": "កែសម្រួលឯកសារ hosts ដោយដៃ ដើម្បីបន្ថែមធាតុខាងក្រោម៖", + "Email": "អ៊ីមែល", + "Embedding": "Embedding", + "Embeddings": "Embeddings", + "Enable": "បើក", + "Enable DNS per tool below to activate interception": "បើក DNS សម្រាប់ឧបករណ៍នីមួយៗខាងក្រោម ដើម្បីធ្វើឱ្យការចាប់សំណើសកម្ម", + "Enable DNS to edit model mappings": "បើក DNS ដើម្បីកែសម្រួលការផ្គូផ្គងម៉ូដែល", + "Enable Observability": "បើក Observability", + "Enable OpenAI API": "បើក OpenAI API", + "Enable Tunnel": "បើក Tunnel", + "Enable connections that still have quota on the current page": "បើកការតភ្ជាប់ដែលនៅមាន Quota លើទំព័របច្ចុប្បន្ន", + "Enable provider": "បើកអ្នកផ្តល់សេវា", + "Enable proxy for OAuth + provider outbound requests.": "បើក Proxy សម្រាប់ OAuth និងសំណើចេញទៅអ្នកផ្តល់សេវា។", + "Encrypted": "បានអ៊ិនគ្រីប", + "End Date": "កាលបរិច្ឆេទបញ្ចប់", + "End-to-end TLS via Cloudflare": "TLS ពីចុងដល់ចុងតាមរយៈ Cloudflare", + "Endpoint": "Endpoint", + "Endpoint & Key": "Endpoint និង Key", + "Endpoint is exposed without an API key.": "Endpoint ត្រូវបានបើកឱ្យចូលប្រើដោយគ្មាន API Key។", + "Enter current password": "បញ្ចូលពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Enter model id": "បញ្ចូល ID ម៉ូដែល", + "Enter model id (provider-specific)": "បញ្ចូល ID ម៉ូដែល (ជាក់លាក់សម្រាប់អ្នកផ្តល់សេវា)", + "Enter new API key": "បញ្ចូល API Key ថ្មី", + "Enter new password": "បញ្ចូលពាក្យសម្ងាត់ថ្មី", + "Enter or pick API key": "បញ្ចូល ឬជ្រើស API Key", + "Enter password": "បញ្ចូលពាក្យសម្ងាត់", + "Enter sudo password": "បញ្ចូលពាក្យសម្ងាត់ sudo", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "បញ្ចូល ID ម៉ូដែលឱ្យត្រឹមត្រូវតាមដែល Endpoint ដែលត្រូវគ្នារបស់អ្នករំពឹងទុក។ ម៉ូដែលនេះនឹងត្រូវរក្សាទុកជាលំនាំដើមនៃការតភ្ជាប់។", + "Enter your API key": "បញ្ចូល API Key របស់អ្នក", + "Enter your current password to": "បញ្ចូលពាក្យសម្ងាត់បច្ចុប្បន្នរបស់អ្នកដើម្បី", + "Enter your password to access the dashboard": "បញ្ចូលពាក្យសម្ងាត់របស់អ្នក ដើម្បីចូលផ្ទាំងគ្រប់គ្រង", + "Error": "កំហុស", + "Est. Cost": "ចំណាយប៉ាន់ស្មាន", + "Estimated, not actual billing": "ជាការប៉ាន់ស្មាន មិនមែនវិក្កយបត្រពិតប្រាកដ", + "Everything you need to manage your AI infrastructure efficiently.": "អ្វីៗគ្រប់យ៉ាងដែលអ្នកត្រូវការ ដើម្បីគ្រប់គ្រងហេដ្ឋារចនាសម្ព័ន្ធ AI ឱ្យមានប្រសិទ្ធភាព។", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "អ្វីៗគ្រប់យ៉ាងដែលអ្នកត្រូវការ ដើម្បីគ្រប់គ្រងហេដ្ឋារចនាសម្ព័ន្ធ AI នៅកន្លែងតែមួយ និងត្រៀមសម្រាប់ការពង្រីក។", + "Example": "ឧទាហរណ៍", + "Experimental": "កំពុងសាកល្បង", + "Expires At": "ផុតកំណត់នៅ", + "Expiring first": "ផុតកំណត់មុន", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "ការរៀបតាមអ្វីដែលផុតកំណត់មុន បច្ចុប្បន្នរៀបគណនីឡើងវិញតែក្នុងទំព័របច្ចុប្បន្ន។ លំដាប់ឆ្លងទំព័រនៅតែអនុវត្តតាម Pagination របស់ Backend។", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "បើក 9Router នៅលើម៉ាស៊ីនរបស់អ្នកឱ្យចូលបានពី Internet។ មិនត្រូវការ Port Forwarding ឬ Static IP ទេ។ ចែករំលែក Endpoint URL ជាមួយក្រុមរបស់អ្នក ឬប្រើវាក្នុង Cursor, Cline និងឧបករណ៍ AI ផ្សេងទៀតពីគ្រប់ទីកន្លែង។", + "Factory Droid - Manual Configuration": "Factory Droid - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Factory Droid AI Assistant": "ជំនួយការ AI Factory Droid", + "Factory Droid CLI not detected locally": "រកមិនឃើញ Factory Droid CLI នៅលើម៉ាស៊ីន", + "Factory Droid CLI not installed": "មិនទាន់បានដំឡើង Factory Droid CLI", + "Fail request if proxy is unreachable instead of falling back to direct.": "ឱ្យសំណើបរាជ័យ ប្រសិនបើមិនអាចភ្ជាប់ទៅ Proxy ជំនួសឱ្យការប្តូរទៅការតភ្ជាប់ផ្ទាល់។", + "Failed": "បរាជ័យ", + "Failed to apply settings": "មិនអាចអនុវត្តការកំណត់បានទេ", + "Failed to create combo": "មិនអាចបង្កើត Combo បានទេ", + "Failed to load changelog:": "មិនអាចផ្ទុកកំណត់ហេតុផ្លាស់ប្តូរ៖", + "Failed to load usage statistics.": "មិនអាចផ្ទុកស្ថិតិការប្រើប្រាស់បានទេ។", + "Failed to reset settings": "មិនអាចកំណត់ការកំណត់ឡើងវិញបានទេ", + "Failed to set alias": "មិនអាចកំណត់ Alias បានទេ", + "Failed to update combo": "មិនអាចធ្វើបច្ចុប្បន្នភាព Combo បានទេ", + "Failed to update password": "មិនអាចធ្វើបច្ចុប្បន្នភាពពាក្យសម្ងាត់បានទេ", + "Failed to update proxy settings": "មិនអាចធ្វើបច្ចុប្បន្នភាពការកំណត់ Proxy បានទេ", + "Fallback": "Fallback", + "Fallback — tries models in order (next on failure)": "Fallback — សាកល្បងម៉ូដែលតាមលំដាប់ (ទៅបន្ទាប់នៅពេលបរាជ័យ)", + "Fallback — try in order": "Fallback — សាកល្បងតាមលំដាប់", + "Features": "មុខងារ", + "Fetch Qoder Models": "ទាញយកម៉ូដែល Qoder", + "Fetching...": "កំពុងទាញយក...", + "Files": "ឯកសារ", + "Filter accounts by status": "តម្រងគណនីតាមស្ថានភាព", + "Filter naming": "តម្រងការដាក់ឈ្មោះ", + "Filter naming requests": "តម្រងសំណើដាក់ឈ្មោះ", + "Filter quota providers": "តម្រងអ្នកផ្តល់សេវាតាម Quota", + "Find MCPs →": "ស្វែងរក MCPs →", + "Find your Account ID in the right sidebar of": "ស្វែងរក ID គណនីរបស់អ្នកនៅរបារចំហៀងខាងស្តាំនៃ", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "ស្វែងរក ID គណនីរបស់អ្នកនៅរបារចំហៀងខាងស្តាំនៃ dash.cloudflare.com", + "First Page": "ទំព័រដំបូង", + "Flush Interval (ms)": "ចន្លោះពេល Flush (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "សម្រាប់អ្នកប្រើប្រាស់ Enterprise ដែលមាន AWS IAM Identity Center ផ្ទាល់ខ្លួន។", + "Forgot password? Open": "ភ្លេចពាក្យសម្ងាត់? បើក", + "Format": "ទម្រង់", + "Found on the right side of the Cloudflare dashboard overview page.": "អាចរកឃើញនៅផ្នែកខាងស្តាំនៃទំព័រទិដ្ឋភាពទូទៅក្នុងផ្ទាំងគ្រប់គ្រង Cloudflare។", + "Free": "ឥតគិតថ្លៃ", + "Free & Free Tier Providers": "អ្នកផ្តល់សេវាឥតគិតថ្លៃ និង Free Tier", + "Free Providers": "អ្នកផ្តល់សេវាឥតគិតថ្លៃ", + "Free Tier": "Free Tier", + "Free Tier Providers": "អ្នកផ្តល់សេវា Free Tier", + "Free tier: 100,000 requests per day": "Free Tier៖ 100,000 សំណើក្នុងមួយថ្ងៃ", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Free Tier៖ Bandwidth 100GB/ខែ និង Edge Invocations 500K", + "Free tier: 1M requests & 100GiB outbound traffic per month": "Free Tier៖ 1M សំណើ និង Traffic ចេញ 100GiB ក្នុងមួយខែ", + "Fresh API key obtained": "បានទទួល API Key ថ្មី", + "Full shell access": "ចូលប្រើ Shell ពេញលេញ", + "Fusion": "Fusion", + "Fusion — panel + judge": "Fusion — Panel + Judge", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "Fusion — សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក Judge សំយោគជាចម្លើយតែមួយ", + "Get 9Remote": "ទាញយក 9Remote", + "Get API Key": "ទទួលបាន API Key", + "Get API Key →": "ទទួលបាន API Key →", + "Get Started": "ចាប់ផ្តើម", + "Get Started in 30 Seconds": "ចាប់ផ្តើមក្នុងរយៈពេល 30 វិនាទី", + "Get started": "ចាប់ផ្តើម", + "Get started in seconds. Just install, open, and route.": "ចាប់ផ្តើមក្នុងពេលប៉ុន្មានវិនាទី។ គ្រាន់តែដំឡើង បើក និងបញ្ជូនផ្លូវ។", + "Get token →": "ទទួលបាន Token →", + "GitHub": "GitHub", + "GitHub Account": "គណនី GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE ជាមួយ MITM", + "GitLab Access Tokens": "GitLab Access Tokens", + "GitLab Applications": "GitLab Applications", + "GitLab Base URL": "GitLab Base URL", + "Go to": "ចូលទៅ", + "Go to Roo Settings panel": "ចូលទៅផ្ទាំងការកំណត់ Roo", + "Google Account": "គណនី Google", + "Google Antigravity IDE with MITM": "Google Antigravity IDE ជាមួយ MITM", + "Granted At": "បានផ្តល់នៅ", + "Group models under one name, then pick a strategy per combo:": "ដាក់ម៉ូដែលជាក្រុមក្រោមឈ្មោះតែមួយ បន្ទាប់មកជ្រើសយុទ្ធសាស្ត្រសម្រាប់ Combo នីមួយៗ៖", + "Headroom proxy is reachable. You can enable the token saver.": "អាចភ្ជាប់ទៅ Headroom Proxy បាន។ អ្នកអាចបើក Token Saver។", + "Help Center": "មជ្ឈមណ្ឌលជំនួយ", + "Hermes Agent - Manual Configuration": "Hermes Agent - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Hermes Agent not detected locally": "រកមិនឃើញ Hermes Agent នៅលើម៉ាស៊ីន", + "Hide": "លាក់", + "Hide key": "លាក់ Key", + "High performance global routing and IP masking via Cloudflare Workers": "ការបញ្ជូនផ្លូវសកលប្រសិទ្ធភាពខ្ពស់ និងការលាក់ IP តាមរយៈ Cloudflare Workers", + "High-performance Rust-based coding agent harness": "Coding Agent Harness ប្រសិទ្ធភាពខ្ពស់ដែលបង្កើតដោយ Rust", + "History": "ប្រវត្តិ", + "How 9Router Works": "របៀបដែល 9Router ដំណើរការ", + "How Pricing Works": "របៀបដែលតម្លៃដំណើរការ", + "How it Works": "របៀបដំណើរការ", + "How it works:": "របៀបដំណើរការ៖", + "How to Install": "របៀបដំឡើង", + "How to generate API token:": "របៀបបង្កើត API Token៖", + "How to generate your API Token:": "របៀបបង្កើត API Token របស់អ្នក៖", + "How to get cookie:": "របៀបទទួល Cookie៖", + "ID:": "ID៖", + "IDC Start URL": "IDC Start URL", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "ប្រសិនបើអ្នកផ្តល់សេវាមិនមាន Endpoint /models សូមបញ្ចូល ID ម៉ូដែល ដើម្បីផ្ទៀងផ្ទាត់តាម chat/completions ជំនួសវិញ។", + "Image Generation": "ការបង្កើតរូបភាព", + "Image to Text": "រូបភាពទៅអត្ថបទ", + "Import": "នាំចូល", + "Import Backup": "នាំចូលការបម្រុងទុក", + "Import CLIProxyAPI JSON": "នាំចូល CLIProxyAPI JSON", + "Import Token": "នាំចូល Token", + "Importing...": "កំពុងនាំចូល...", + "In": "ចូល", + "In / Out": "ចូល / ចេញ", + "Inactive": "អសកម្ម", + "Inactive pools are ignored by runtime resolution.": "Pools អសកម្មត្រូវបានរំលងដោយការដោះស្រាយនៅពេលដំណើរការ។", + "Inc. All rights reserved.": "Inc. រក្សាសិទ្ធិគ្រប់យ៉ាង។", + "Initializing...": "កំពុងចាប់ផ្តើម...", + "Input": "Input", + "Input Cost": "ចំណាយ Input", + "Input Tokens": "Input Tokens", + "Input Tokens:": "Input Tokens៖", + "Input:": "Input៖", + "Install 9Router": "ដំឡើង 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "ដំឡើង 9Router កំណត់អ្នកផ្តល់សេវារបស់អ្នកតាមផ្ទាំងគ្រប់គ្រងលើវេប ហើយចាប់ផ្តើមបញ្ជូនសំណើ AI។", + "Install Chrome extension": "ដំឡើង Chrome Extension", + "Install Cline VS Code extension or CLI from": "ដំឡើង Cline VS Code Extension ឬ CLI ពី", + "Install Kilo Code from": "ដំឡើង Kilo Code ពី", + "Install Qwen Code": "ដំឡើង Qwen Code", + "Install Tailscale": "ដំឡើង Tailscale", + "Install command:": "Command ដំឡើង៖", + "Install jcode to enable automatic configuration:": "ដំឡើង jcode ដើម្បីបើកការកំណត់រចនាសម្ព័ន្ធដោយស្វ័យប្រវត្តិ៖", + "Install the Amp CLI using the package manager supported by your environment.": "ដំឡើង Amp CLI ដោយប្រើ Package Manager ដែល Environment របស់អ្នកគាំទ្រ។", + "Install then click Start:": "ដំឡើង បន្ទាប់មកចុច ចាប់ផ្តើម៖", + "Install via npm:": "ដំឡើងតាម npm៖", + "Installation Guide": "មគ្គុទ្ទេសក៍ដំឡើង", + "Installing Tailscale...": "កំពុងដំឡើង Tailscale...", + "Interactive diagram visible on desktop": "ដ្យាក្រាមអន្តរកម្មបង្ហាញនៅលើផ្ទៃតុ", + "Intercept CLI tool traffic and route through 9Router": "ចាប់ Traffic របស់ឧបករណ៍ CLI ហើយបញ្ជូនតាម 9Router", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ចាប់ Traffic របស់ Antigravity តាម DNS Redirect ដើម្បីឱ្យអ្នកអាចបញ្ជូនម៉ូដែលឡើងវិញតាម 9Router។", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "ចាប់សំណើដាក់ឈ្មោះប្រធានបទរបស់ Claude Code ហើយត្រឡប់ការឆ្លើយតបក្លែងក្លាយនៅលើម៉ាស៊ីន ដើម្បីសន្សំ API Tokens។", + "Invalid": "មិនត្រឹមត្រូវ", + "Invalid password": "ពាក្យសម្ងាត់មិនត្រឹមត្រូវ", + "Issuer URL": "Issuer URL", + "JSON Response": "ការឆ្លើយតប JSON", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "ចូលរួមជាមួយអ្នកអភិវឌ្ឍន៍ដែលកំពុងសម្រួលការរួមបញ្ចូល AI របស់ពួកគេជាមួយ 9Router។ Open Source និងចាប់ផ្តើមដោយឥតគិតថ្លៃ។", + "Judge": "Judge", + "Just now": "អម្បាញ់មិញ", + "KB per field": "KB ក្នុងមួយ Field", + "Keep the legacy password login.": "រក្សាការចូលដោយពាក្យសម្ងាត់ចាស់។", + "Key Name": "ឈ្មោះ Key", + "KiRo dashboard": "ផ្ទាំងគ្រប់គ្រង KiRo", + "Kill & Start": "បញ្ឈប់ និងចាប់ផ្តើម", + "Kill this process to start MITM Server?": "បញ្ឈប់ Process នេះ ដើម្បីចាប់ផ្តើមម៉ាស៊ីនមេ MITM មែនទេ?", + "Kilo Code - Manual Configuration": "Kilo Code - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Kilo Code AI Assistant": "ជំនួយការ AI Kilo Code", + "Kilo Code not detected locally": "រកមិនឃើញ Kilo Code នៅលើម៉ាស៊ីន", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "រកមិនឃើញ Kiro IDE។ សូមបិទភ្ជាប់ Refresh Token របស់អ្នកដោយដៃ។", + "Kiro IDE with MITM": "Kiro IDE ជាមួយ MITM", + "Language": "ភាសា", + "Languages": "ភាសា", + "Last Page": "ទំព័រចុងក្រោយ", + "Last Used": "បានប្រើចុងក្រោយ", + "Last tested:": "បានសាកល្បងចុងក្រោយ៖", + "Last updated:": "បានធ្វើបច្ចុប្បន្នភាពចុងក្រោយ៖", + "Latency": "រយៈពេលឆ្លើយតប", + "Latency:": "រយៈពេលឆ្លើយតប៖", + "Lazy senior dev": "Senior Dev បែបសន្សំកម្លាំង", + "Lean": "សង្ខេប", + "Leave blank to keep existing secret": "ទុកឱ្យទទេ ដើម្បីរក្សា Secret ដែលមានស្រាប់", + "Leave blank to use": "ទុកឱ្យទទេ ដើម្បីប្រើ", + "Leave empty for public PKCE app": "ទុកឱ្យទទេសម្រាប់ Public PKCE App", + "Leave empty to inherit existing env proxy (if any).": "ទុកឱ្យទទេ ដើម្បីទទួល Proxy ពី Environment ដែលមានស្រាប់ (បើមាន)។", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "API នៅតែទទួលយក Fields Proxy ដោយដៃចាស់ ដើម្បីរក្សាភាពត្រូវគ្នាជាមួយកំណែមុន។", + "Legacy:": "កំណែចាស់៖", + "Legal": "ផ្នែកច្បាប់", + "Live server console output": "Console Output ផ្ទាល់ពីម៉ាស៊ីនមេ", + "Load": "ផ្ទុក", + "Loading logs...": "កំពុងផ្ទុកកំណត់ហេតុ...", + "Loading models from provider...": "កំពុងផ្ទុកម៉ូដែលពីអ្នកផ្តល់សេវា...", + "Loading pricing data...": "កំពុងផ្ទុកទិន្នន័យតម្លៃ...", + "Loading registry...": "កំពុងផ្ទុក Registry...", + "Loading reset credits...": "កំពុងផ្ទុក Credit កំណត់ឡើងវិញ...", + "Loading...": "កំពុងផ្ទុក...", + "Local": "លើម៉ាស៊ីន", + "Local Mode": "របៀបលើម៉ាស៊ីន", + "Local Mode - All data stored on your machine": "របៀបលើម៉ាស៊ីន - ទិន្នន័យទាំងអស់រក្សាទុកនៅលើម៉ាស៊ីនរបស់អ្នក", + "Local Plugins": "Plugins លើម៉ាស៊ីន", + "Locked. Retry in": "បានចាក់សោ។ ព្យាយាមម្តងទៀតក្នុង", + "Login": "ចូល", + "Login Button Label": "ស្លាកប៊ូតុងចូល", + "Login URL": "URL សម្រាប់ចូល", + "Login to your account": "ចូលគណនីរបស់អ្នក", + "Login with your GitHub account (manual callback).": "ចូលដោយគណនី GitHub របស់អ្នក (Callback ដោយដៃ)។", + "Login with your Google account (manual callback).": "ចូលដោយគណនី Google របស់អ្នក (Callback ដោយដៃ)។", + "Logout": "ចាកចេញ", + "Logs": "កំណត់ហេតុ", + "Logs are loaded from the request history database.": "កំណត់ហេតុត្រូវបានផ្ទុកពីមូលដ្ឋានទិន្នន័យប្រវត្តិសំណើ។", + "Logs are saved to log.txt in the application data directory.": "កំណត់ហេតុត្រូវបានរក្សាទុកក្នុង log.txt នៅថតទិន្នន័យកម្មវិធី។", + "MIT License": "MIT License", + "MITM": "MITM", + "MITM Proxy": "MITM Proxy", + "MITM Server": "ម៉ាស៊ីនមេ MITM", + "MITM Tools": "ឧបករណ៍ MITM", + "Machine ID": "Machine ID", + "Machine ID will be auto-filled...": "Machine ID នឹងត្រូវបានបំពេញដោយស្វ័យប្រវត្តិ...", + "Make sure Cursor IDE has been opened at least once, then click": "ត្រូវប្រាកដថាបានបើក Cursor IDE យ៉ាងហោចណាស់ម្តង បន្ទាប់មកចុច", + "Manage": "គ្រប់គ្រង", + "Manage reusable per-connection proxies and bind them to provider connections.": "គ្រប់គ្រង Proxies ដែលអាចប្រើឡើងវិញសម្រាប់ការតភ្ជាប់នីមួយៗ និងភ្ជាប់ពួកវាទៅការតភ្ជាប់អ្នកផ្តល់សេវា។", + "Manage your AI provider connections": "គ្រប់គ្រងការតភ្ជាប់អ្នកផ្តល់សេវា AI របស់អ្នក", + "Manage your Embedding providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Embedding របស់អ្នក", + "Manage your Image to Text providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា រូបភាពទៅអត្ថបទ របស់អ្នក", + "Manage your Music providers": "គ្រប់គ្រងអ្នកផ្តល់សេវាតន្ត្រីរបស់អ្នក", + "Manage your Speech To Text providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Speech To Text របស់អ្នក", + "Manage your Text To Speech providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Text To Speech របស់អ្នក", + "Manage your Text to Image providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា អត្ថបទទៅរូបភាព របស់អ្នក", + "Manage your Video providers": "គ្រប់គ្រងអ្នកផ្តល់សេវាវីដេអូរបស់អ្នក", + "Manage your Web Fetch providers": "គ្រប់គ្រងអ្នកផ្តល់សេវាទាញយកខ្លឹមសារវេបរបស់អ្នក", + "Manage your Web Search providers": "គ្រប់គ្រងអ្នកផ្តល់សេវាស្វែងរកលើវេបរបស់អ្នក", + "Manage your preferences": "គ្រប់គ្រងចំណូលចិត្តរបស់អ្នក", + "Manage your proxy pool configurations": "គ្រប់គ្រងការកំណត់រចនាសម្ព័ន្ធ Proxy Pool របស់អ្នក", + "Manual / current endpoint": "Endpoint ដោយដៃ / បច្ចុប្បន្ន", + "Manual Callback Required": "ត្រូវការ Callback ដោយដៃ", + "Manual Config": "កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Manual configuration is still available if 9router is deployed on a remote server.": "ការកំណត់រចនាសម្ព័ន្ធដោយដៃនៅតែអាចប្រើបាន ប្រសិនបើ 9router ត្រូវបានដាក់ឱ្យដំណើរការលើម៉ាស៊ីនមេពីចម្ងាយ។", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "ផ្គូផ្គងឈ្មោះកាត់ Amp ដូចជា g25p ឬ cs45 ទៅ Aliases របស់ 9Router ក្នុង Config លើម៉ាស៊ីនរបស់អ្នក។", + "Mask (URL)": "Mask (URL)", + "Max JSON Size (KB)": "ទំហំ JSON អតិបរមា (KB)", + "Max Records": "ចំនួន Records អតិបរមា", + "Maximum request detail records to keep (older records are auto-deleted)": "ចំនួន Records ព័ត៌មានលម្អិតសំណើអតិបរមាដែលត្រូវរក្សា (Records ចាស់នឹងត្រូវលុបដោយស្វ័យប្រវត្តិ)", + "Maximum size for each JSON field (request/response) before truncation": "ទំហំអតិបរមាសម្រាប់ JSON Field នីមួយៗ (សំណើ/ការឆ្លើយតប) មុនពេលកាត់ខ្លី", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "រយៈពេលរង់ចាំអតិបរមាមុនពេល Flush Buffer (ការពារការបាត់ទិន្នន័យនៅពេល Traffic ទាប)", + "Media Providers": "អ្នកផ្តល់សេវាមេឌៀ", + "Menu": "ម៉ឺនុយ", + "Message AI": "ផ្ញើសារទៅ AI", + "Messages": "សារ", + "Messages API": "Messages API", + "MiniMax": "MiniMax", + "Model": "ម៉ូដែល", + "Model Fallback": "Fallback ម៉ូដែល", + "Model ID": "ID ម៉ូដែល", + "Model ID (from OpenRouter)": "ID ម៉ូដែល (ពី OpenRouter)", + "Model ID (optional)": "ID ម៉ូដែល (ជាជម្រើស)", + "Model Status": "ស្ថានភាពម៉ូដែល", + "Model combos": "Combo ម៉ូដែល", + "Model combos with fallback": "Combo ម៉ូដែលជាមួយ Fallback", + "Model is reachable": "អាចភ្ជាប់ទៅម៉ូដែលបាន", + "Model list is filtered from connected providers.": "បញ្ជីម៉ូដែលត្រូវបានតម្រងពីអ្នកផ្តល់សេវាដែលបានភ្ជាប់។", + "Model mappings will be available soon.": "ការផ្គូផ្គងម៉ូដែលនឹងមានឆាប់ៗនេះ។", + "Model not reachable": "មិនអាចភ្ជាប់ទៅម៉ូដែលបាន", + "Model:": "ម៉ូដែល៖", + "Models": "ម៉ូដែល", + "Monitor your API usage, token consumption, and request logs": "តាមដានការប្រើប្រាស់ API ការប្រើ Token និងកំណត់ហេតុសំណើរបស់អ្នក", + "More on GitHub": "មើលបន្ថែមនៅ GitHub", + "Move down": "ផ្លាស់ទីចុះក្រោម", + "Move up": "ផ្លាស់ទីឡើងលើ", + "Music": "តន្ត្រី", + "My Profile": "ប្រវត្តិរូបរបស់ខ្ញុំ", + "N/A": "មិនមាន", + "NPM": "NPM", + "Name": "ឈ្មោះ", + "Name is required": "ត្រូវការឈ្មោះ", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "គាំទ្រឧបករណ៍ CLI ដោយផ្ទាល់សម្រាប់ Cursor, Claude, Copilot និងផ្សេងទៀត។", + "Navigate to home": "ទៅទំព័រដើម", + "Network": "បណ្តាញ", + "Network Error": "កំហុសបណ្តាញ", + "Network error": "កំហុសបណ្តាញ", + "Never": "មិនដែល", + "New Password": "ពាក្យសម្ងាត់ថ្មី", + "New password": "ពាក្យសម្ងាត់ថ្មី", + "Next": "បន្ទាប់", + "Next accounts page": "ទំព័រគណនីបន្ទាប់", + "No API keys - Create one in Keys page": "មិនមាន API Keys — បង្កើតមួយនៅទំព័រ Keys", + "No API keys yet": "មិនទាន់មាន API Keys", + "No MCPs added": "មិនទាន់បានបន្ថែម MCPs", + "No Providers Connected": "មិនមានអ្នកផ្តល់សេវាដែលបានភ្ជាប់", + "No Proxy": "គ្មាន Proxy", + "No active connections found for this group.": "រកមិនឃើញការតភ្ជាប់សកម្មសម្រាប់ក្រុមនេះ។", + "No active providers": "មិនមានអ្នកផ្តល់សេវាសកម្ម", + "No active proxy pools available. Create one in Proxy Pools page first.": "មិនមាន Proxy Pools សកម្មទេ។ សូមបង្កើតមួយនៅទំព័រ Proxy Pools ជាមុនសិន។", + "No authentication required": "មិនត្រូវការការផ្ទៀងផ្ទាត់", + "No combos yet": "មិនទាន់មាន Combos", + "No combos yet.": "មិនទាន់មាន Combos។", + "No compatible providers added yet": "មិនទាន់បានបន្ថែមអ្នកផ្តល់សេវាដែលត្រូវគ្នា", + "No connections": "មិនមានការតភ្ជាប់", + "No connections yet": "មិនទាន់មានការតភ្ជាប់", + "No console logs yet.": "មិនទាន់មាន Console Logs។", + "No conversations yet.": "មិនទាន់មានការសន្ទនា។", + "No custom providers": "មិនមានអ្នកផ្តល់សេវាផ្ទាល់ខ្លួន", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "មិនមានអ្នកផ្តល់សេវាផ្ទាល់ខ្លួន — ប្រើប៊ូតុងខាងលើដើម្បីបន្ថែម Endpoints ដែលត្រូវគ្នាជាមួយ OpenAI/Anthropic", + "No data for this period": "មិនមានទិន្នន័យសម្រាប់រយៈពេលនេះ", + "No key configured": "មិនទាន់បានកំណត់ Key", + "No language selected": "មិនទាន់បានជ្រើសភាសា", + "No languages found.": "រកមិនឃើញភាសា។", + "No logs recorded yet.": "មិនទាន់មានកំណត់ហេតុ។", + "No model selected.": "មិនទាន់បានជ្រើសម៉ូដែល។", + "No models": "មិនមានម៉ូដែល", + "No models added yet": "មិនទាន់បានបន្ថែមម៉ូដែល", + "No models configured": "មិនទាន់បានកំណត់ម៉ូដែល", + "No models found": "រកមិនឃើញម៉ូដែល", + "No models match your filter.": "មិនមានម៉ូដែលដែលត្រូវនឹងតម្រងរបស់អ្នក។", + "No models selected": "មិនទាន់បានជ្រើសម៉ូដែល", + "No port forwarding needed": "មិនត្រូវការ Port Forwarding", + "No pricing data available": "មិនមានទិន្នន័យតម្លៃ", + "No providers connected": "មិនមានអ្នកផ្តល់សេវាដែលបានភ្ជាប់", + "No providers match your search": "មិនមានអ្នកផ្តល់សេវាដែលត្រូវនឹងការស្វែងរករបស់អ្នក", + "No providers support": "មិនមានអ្នកផ្តល់សេវាដែលគាំទ្រ", + "No providers yet.": "មិនទាន់មានអ្នកផ្តល់សេវា។", + "No providers.": "មិនមានអ្នកផ្តល់សេវា។", + "No proxy pool entries yet": "មិនទាន់មានធាតុ Proxy Pool", + "No proxy:": "គ្មាន Proxy៖", + "No quota data available": "មិនមានទិន្នន័យ Quota", + "No request details found": "រកមិនឃើញព័ត៌មានលម្អិតសំណើ", + "No requests yet.": "មិនទាន់មានសំណើ។", + "No reset credit details returned for this account.": "មិនមានព័ត៌មានលម្អិត Credit កំណត់ឡើងវិញសម្រាប់គណនីនេះទេ។", + "No results": "មិនមានលទ្ធផល", + "No servers match filter": "មិនមានម៉ាស៊ីនមេដែលត្រូវនឹងតម្រង", + "No tools advertised by server.": "ម៉ាស៊ីនមេមិនបានប្រកាសឧបករណ៍ណាមួយទេ។", + "No usage yet.": "មិនទាន់មានការប្រើប្រាស់។", + "None": "គ្មាន", + "None (unbind all)": "គ្មាន (ដកការភ្ជាប់ទាំងអស់)", + "Not configured": "មិនទាន់បានកំណត់", + "Not installed": "មិនទាន់បានដំឡើង", + "Notice": "សេចក្តីជូនដំណឹង", + "Nous Research self-improving AI agent": "AI Agent ដែលអាចកែលម្អខ្លួនឯងរបស់ Nous Research", + "Number of items to accumulate before writing to database (higher = better performance)": "ចំនួនធាតុដែលត្រូវប្រមូល មុនពេលសរសេរទៅមូលដ្ឋានទិន្នន័យ (កាន់តែខ្ពស់ = ប្រសិទ្ធភាពកាន់តែល្អ)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth និង API Keys", + "OAuth Account": "គណនី OAuth", + "OAuth App": "OAuth App", + "OAuth Providers": "អ្នកផ្តល់សេវា OAuth", + "OAuth required": "ត្រូវការ OAuth", + "OK": "ជោគជ័យ", + "OIDC Dashboard Login": "ការចូលផ្ទាំងគ្រប់គ្រងដោយ OIDC", + "OIDC active": "OIDC សកម្ម", + "OIDC login is currently active. Password login is disabled until you switch back.": "ការចូលដោយ OIDC បច្ចុប្បន្នសកម្ម។ ការចូលដោយពាក្យសម្ងាត់ត្រូវបានបិទ រហូតដល់អ្នកប្តូរត្រឡប់វិញ។", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "ការចូលដោយ OIDC ត្រូវបានបើក ប៉ុន្តែ Fields របស់ Issuer/Client មិនទាន់បានកំណត់ទេ។ ការចូលដោយពាក្យសម្ងាត់នៅតែអាចប្រើសម្រាប់ការសង្គ្រោះ។", + "OIDC only": "តែ OIDC", + "Observability": "Observability", + "Office Proxy": "Office Proxy", + "Ollama Host URL": "Ollama Host URL", + "One Endpoint for": "Endpoint តែមួយសម្រាប់", + "One key per line. Format:": "មួយ Key ក្នុងមួយបន្ទាត់។ ទម្រង់៖", + "One-to-one (rotate)": "មួយទល់មួយ (ប្តូរវេន)", + "Only from connected providers": "តែពីអ្នកផ្តល់សេវាដែលបានភ្ជាប់", + "Only letters, numbers, - and _ allowed": "អនុញ្ញាតតែអក្សរ លេខ - និង _", + "Only letters, numbers, -, _ and .": "តែអក្សរ លេខ - _ និង .", + "Only letters, numbers, -, _ and . allowed": "អនុញ្ញាតតែអក្សរ លេខ - _ និង .", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "Node ដែលត្រូវគ្នានីមួយៗ អនុញ្ញាតការតភ្ជាប់តែមួយប៉ុណ្ណោះ។ បន្ថែម Node មួយទៀត ប្រសិនបើអ្នកត្រូវការការតភ្ជាប់បន្ថែម។", + "Open": "បើក", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "បើក Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference បន្ទាប់មកត្រឡប់មកទីនេះ។", + "Open Claw - Manual Configuration": "Open Claw - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Open Claw AI Assistant": "ជំនួយការ AI Open Claw", + "Open Claw CLI not detected locally": "រកមិនឃើញ Open Claw CLI នៅលើម៉ាស៊ីន", + "Open Claw CLI not installed": "មិនទាន់បានដំឡើង Open Claw CLI", + "Open Continue configuration file": "បើកឯកសារ Config របស់ Continue", + "Open Dashboard": "បើកផ្ទាំងគ្រប់គ្រង", + "Open DevTools (F12) → Application/Storage → Cookies": "បើក DevTools (F12) → Application/Storage → Cookies", + "Open Settings": "បើកការកំណត់", + "Open platform.iflow.cn in your browser": "បើក platform.iflow.cn ក្នុង browser របស់អ្នក", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "សំឡេង OpenAI / ElevenLabs / Edge / Google / Deepgram។", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "OpenAI Compatible (Production)", + "OpenAI Compatible Details": "ព័ត៌មានលម្អិត OpenAI Compatible", + "OpenAI Intermediate": "OpenAI Intermediate", + "OpenAI Response": "ការឆ្លើយតប OpenAI", + "OpenCode - Manual Configuration": "OpenCode - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "OpenCode AI Terminal Assistant": "ជំនួយការ AI Terminal OpenCode", + "OpenCode CLI not detected locally": "រកមិនឃើញ OpenCode CLI នៅលើម៉ាស៊ីន", + "OpenCode CLI not installed": "មិនទាន់បានដំឡើង OpenCode CLI", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter គាំទ្រម៉ូដែលណាមួយ។ បន្ថែមម៉ូដែល និងបង្កើត Aliases ដើម្បីចូលប្រើបានរហ័ស។", + "Optional SSO via Authentik/Keycloak/Google": "SSO ជាជម្រើសតាមរយៈ Authentik/Keycloak/Google", + "Or paste callback URL manually": "ឬបិទភ្ជាប់ Callback URL ដោយដៃ", + "Organization": "អង្គភាព", + "Organization Domain": "Domain របស់អង្គភាព", + "Organization ID": "ID អង្គភាព", + "Organization Token": "Token អង្គភាព", + "Organization Tokens": "Tokens អង្គភាព", + "Other": "ផ្សេងទៀត", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "Engine របស់យើងវិភាគ Prompt ហើយបញ្ជូនផ្លូវតាម Subscription របស់អ្នក អ្នកផ្តល់សេវាតម្លៃថោក និង Free Tier ជាមួយ Fallback ដោយស្វ័យប្រវត្តិ។", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Engine របស់យើងវិភាគ Prompt ពិនិត្យស្ថានភាពអ្នកផ្តល់សេវា ហើយបញ្ជូនផ្លូវដើម្បីទទួលបានរយៈពេលឆ្លើយតប ឬចំណាយទាបបំផុត។", + "Out": "ចេញ", + "Outbound Proxy": "Proxy ចេញ", + "Output": "Output", + "Output Cost": "ចំណាយ Output", + "Output Format": "ទម្រង់ Output", + "Output Tokens": "Output Tokens", + "Output Tokens:": "Output Tokens៖", + "Output:": "Output៖", + "Overview": "ទិដ្ឋភាពទូទៅ", + "Paid": "បង់ប្រាក់", + "Partial preview": "ការមើលជាមុនមួយផ្នែក", + "Password": "ពាក្យសម្ងាត់", + "Password + OIDC active": "ពាក្យសម្ងាត់ + OIDC សកម្ម", + "Password and OIDC login are both active.": "ការចូលដោយពាក្យសម្ងាត់ និង OIDC សុទ្ធតែសកម្ម។", + "Password and OIDC login are both enabled.": "ការចូលដោយពាក្យសម្ងាត់ និង OIDC សុទ្ធតែបានបើក។", + "Password only": "តែពាក្យសម្ងាត់", + "Password updated successfully": "បានធ្វើបច្ចុប្បន្នភាពពាក្យសម្ងាត់ដោយជោគជ័យ", + "Passwords do not match": "ពាក្យសម្ងាត់មិនត្រូវគ្នា", + "Paste Proxy List (One per line)": "បិទភ្ជាប់បញ្ជី Proxy (មួយក្នុងមួយបន្ទាត់)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "បិទភ្ជាប់ Kiro/CodeWhisperer API Key ដែលមានអាយុកាលវែង។ វាត្រូវបានផ្ទៀងផ្ទាត់ជាមួយ AWS ហើយរក្សាទុកដោយផ្ទាល់ជា Bearer Credential (មិនមាន Refresh)។", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "បិទភ្ជាប់ external_idp auth JSON ពីការចូល CLIProxyAPI/Kiro Microsoft។", + "Paste it below": "បិទភ្ជាប់វាខាងក្រោម", + "Paste refresh token from Kiro IDE.": "បិទភ្ជាប់ Refresh Token ពី Kiro IDE។", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "បិទភ្ជាប់ Kiro CLIProxyAPI auth JSON ដែលមាន auth_method=external_idp។ ទទួលយកតែ Token Endpoints សម្រាប់ការចូល Microsoft ប៉ុណ្ណោះ។", + "Paste the URL from your browser address bar": "បិទភ្ជាប់ URL ពីរបារអាសយដ្ឋានរបស់ browser", + "Paste the command into your terminal and press Enter.": "បិទភ្ជាប់ Command ទៅក្នុង terminal របស់អ្នក ហើយចុច Enter។", + "Paste this to your AI:": "បិទភ្ជាប់វាទៅ AI របស់អ្នក៖", + "Paste your Kiro API key...": "បិទភ្ជាប់ Kiro API Key របស់អ្នក...", + "Pause API Key": "ផ្អាក API Key", + "Pause key": "ផ្អាក Key", + "Paused": "បានផ្អាក", + "Permissions": "សិទ្ធិ", + "Personal Access Token": "Personal Access Token", + "Pick the model that fuses panel answers": "ជ្រើសម៉ូដែលដែលសំយោគចម្លើយពី Panel", + "Please add an active Qoder connection first": "សូមបន្ថែមការតភ្ជាប់ Qoder សកម្មជាមុនសិន", + "Please add and connect providers first to configure CLI tools.": "សូមបន្ថែម និងភ្ជាប់អ្នកផ្តល់សេវាជាមុនសិន ដើម្បីកំណត់រចនាសម្ព័ន្ធឧបករណ៍ CLI។", + "Please copy the URL from the address bar and paste it in the application.": "សូមចម្លង URL ពីរបារអាសយដ្ឋាន ហើយបិទភ្ជាប់វាក្នុងកម្មវិធី។", + "Please enter a Proxy URL to test": "សូមបញ្ចូល Proxy URL ដើម្បីសាកល្បង", + "Please install Claude CLI to use this feature.": "សូមដំឡើង Claude CLI ដើម្បីប្រើមុខងារនេះ។", + "Please install Codex CLI to use auto-apply feature.": "សូមដំឡើង Codex CLI ដើម្បីប្រើមុខងារអនុវត្តស្វ័យប្រវត្តិ។", + "Please install Factory Droid CLI to use this feature.": "សូមដំឡើង Factory Droid CLI ដើម្បីប្រើមុខងារនេះ។", + "Please install Open Claw CLI to use this feature.": "សូមដំឡើង Open Claw CLI ដើម្បីប្រើមុខងារនេះ។", + "Please install OpenCode CLI to use auto-apply feature.": "សូមដំឡើង OpenCode CLI ដើម្បីប្រើមុខងារអនុវត្តស្វ័យប្រវត្តិ។", + "Please wait while we complete the authorization.": "សូមរង់ចាំ ខណៈពេលយើងបញ្ចប់ការផ្តល់សិទ្ធិ។", + "Point your CLI tools to http://localhost:20128": "កំណត់ឧបករណ៍ CLI របស់អ្នកទៅ http://localhost:20128", + "Pool:": "Pool៖", + "Popup blocked? Enter URL manually": "Popup ត្រូវបានទប់ស្កាត់? បញ្ចូល URL ដោយដៃ", + "Port 443 Already In Use": "Port 443 កំពុងត្រូវបានប្រើ", + "Port 443 is currently used by another process:": "Port 443 បច្ចុប្បន្នកំពុងត្រូវបានប្រើដោយ Process មួយទៀត៖", + "Powerful Features": "មុខងារដ៏មានអានុភាព", + "Prefix": "Prefix", + "Preset": "ការកំណត់ជាមុន", + "Prev": "មុន", + "Preview": "មើលជាមុន", + "Previous accounts page": "ទំព័រគណនីមុន", + "Pricing": "តម្លៃ", + "Pricing Configuration": "ការកំណត់រចនាសម្ព័ន្ធតម្លៃ", + "Pricing Format:": "ទម្រង់តម្លៃ៖", + "Pricing Rates Format": "ទម្រង់អត្រាតម្លៃ", + "Pricing Settings": "ការកំណត់តម្លៃ", + "Priority": "អាទិភាព", + "Privacy Policy": "គោលការណ៍ឯកជនភាព", + "Probing server for tools...": "កំពុងពិនិត្យម៉ាស៊ីនមេរកឧបករណ៍...", + "Processing...": "កំពុងដំណើរការ...", + "Product": "ផលិតផល", + "Production Key": "Production Key", + "Project Name": "ឈ្មោះ Project", + "Prompt": "Prompt", + "Provider": "អ្នកផ្តល់សេវា", + "Provider Details": "ព័ត៌មានលម្អិតអ្នកផ្តល់សេវា", + "Provider Limits": "កម្រិតអ្នកផ្តល់សេវា", + "Provider Response": "ការឆ្លើយតបពីអ្នកផ្តល់សេវា", + "Provider not found": "រកមិនឃើញអ្នកផ្តល់សេវា", + "Provider test failed": "ការសាកល្បងអ្នកផ្តល់សេវាបានបរាជ័យ", + "Provider:": "អ្នកផ្តល់សេវា៖", + "Providers": "អ្នកផ្តល់សេវា", + "Proxy": "Proxy", + "Proxy Action": "សកម្មភាព Proxy", + "Proxy Pool": "Proxy Pool", + "Proxy Pools": "Proxy Pools", + "Proxy URL": "Proxy URL", + "Proxy disabled": "Proxy បានបិទ", + "Proxy enabled": "Proxy បានបើក", + "Proxy pool created": "បានបង្កើត Proxy Pool", + "Proxy pool deleted": "បានលុប Proxy Pool", + "Proxy pool updated": "បានធ្វើបច្ចុប្បន្នភាព Proxy Pool", + "Proxy settings applied": "បានអនុវត្តការកំណត់ Proxy", + "Proxy test OK": "ការសាកល្បង Proxy ជោគជ័យ", + "Proxy test failed": "ការសាកល្បង Proxy បានបរាជ័យ", + "Proxy test passed": "ការសាកល្បង Proxy បានជោគជ័យ", + "Purpose:": "គោលបំណង៖", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "របៀបគ្រប់គ្រងលើម៉ាស៊ីនត្រូវការ Python >= 3.10។ សូមដំឡើង Python ជាមុន ឬប្រើ Proxy URL ខាងក្រៅ។", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "របៀបគ្រប់គ្រងលើម៉ាស៊ីនត្រូវការ Python ≥ 3.10។ សូមដំឡើង Python ជាមុន ឬប្រើ Proxy URL ខាងក្រៅ។", + "Quota Tracker": "កម្មវិធីតាមដាន Quota", + "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code គាំទ្រប្រភេទអ្នកផ្តល់សេវាច្រើន (openai, anthropic, gemini) តាម modelProviders ក្នុង settings.json។ 9Router ដំណើរការជា Endpoint ដែលត្រូវគ្នាជាមួយ OpenAI។", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "Qwen OAuth Free Tier ត្រូវបានបញ្ឈប់នៅថ្ងៃទី 2026-04-15។ សូមប្រើ 9Router ជាមួយអ្នកផ្តល់សេវា alicode/openrouter/anthropic/gemini ជំនួសវិញ។", + "Rate Limited": "ត្រូវបានកម្រិតអត្រា", + "Read Documentation": "អានឯកសារ", + "Reading from AWS SSO cache": "កំពុងអានពី AWS SSO Cache", + "Reading from Cursor IDE database": "កំពុងអានពីមូលដ្ឋានទិន្នន័យ Cursor IDE", + "Ready": "រួចរាល់", + "Ready to Simplify Your AI Infrastructure?": "ត្រៀមខ្លួនសម្រួលហេដ្ឋារចនាសម្ព័ន្ធ AI របស់អ្នកហើយឬនៅ?", + "Ready to route! ✓": "រួចរាល់សម្រាប់បញ្ជូនផ្លូវ! ✓", + "Ready! Requests route automatically through your configured providers.": "រួចរាល់! សំណើត្រូវបានបញ្ជូនផ្លូវដោយស្វ័យប្រវត្តិតាមអ្នកផ្តល់សេវាដែលអ្នកបានកំណត់។", + "Reasoning": "ការគិតហេតុផល", + "Reasoning:": "ការគិតហេតុផល៖", + "Recent Requests": "សំណើថ្មីៗ", + "Recent chats": "ការជជែកថ្មីៗ", + "Recheck": "ពិនិត្យម្តងទៀត", + "Recommended for most users. Free AWS account required.": "ណែនាំសម្រាប់អ្នកប្រើភាគច្រើន។ ត្រូវការគណនី AWS ឥតគិតថ្លៃ។", + "Record request details for inspection in the logs view": "កត់ត្រាព័ត៌មានលម្អិតសំណើ សម្រាប់ពិនិត្យក្នុងទិដ្ឋភាពកំណត់ហេតុ", + "Redirect URI": "Redirect URI", + "Ref Image (URL)": "រូបភាពយោង (URL)", + "Refresh": "ផ្ទុកឡើងវិញ", + "Refresh All": "ផ្ទុកទាំងអស់ឡើងវិញ", + "Refresh Token": "Refresh Token", + "Refresh all": "ផ្ទុកទាំងអស់ឡើងវិញ", + "Refresh quota": "ផ្ទុក Quota ឡើងវិញ", + "Region": "តំបន់", + "Reload Page": "ផ្ទុកទំព័រឡើងវិញ", + "Reload VS Code after applying for changes to take effect.": "ផ្ទុក VS Code ឡើងវិញបន្ទាប់ពីអនុវត្ត ដើម្បីឱ្យការផ្លាស់ប្តូរមានប្រសិទ្ធភាព។", + "Remaining": "នៅសល់", + "Remote": "ពីចម្ងាយ", + "Remove": "ដកចេញ", + "Remove attachment": "ដកឯកសារភ្ជាប់ចេញ", + "Remove custom model": "ដកម៉ូដែលផ្ទាល់ខ្លួនចេញ", + "Remove model": "ដកម៉ូដែលចេញ", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "ជំនួស WebSearch/WebFetch ដែលភ្ជាប់មកជាមួយ។ ដកធាតុស្ទួនចេញពីបញ្ជីឧបករណ៍ដោយស្វ័យប្រវត្តិ។", + "Replay request flow — matches log files": "ចាក់លំហូរសំណើឡើងវិញ — ត្រូវគ្នានឹងឯកសារកំណត់ហេតុ", + "Request": "សំណើ", + "Request Details": "ព័ត៌មានលម្អិតសំណើ", + "Request Logs": "កំណត់ហេតុសំណើ", + "Requests": "សំណើ", + "Requests without a valid key will be rejected": "សំណើដែលគ្មាន Key ត្រឹមត្រូវនឹងត្រូវបដិសេធ", + "Require API key": "ត្រូវការ API Key", + "Require OIDC for dashboard access.": "ត្រូវការ OIDC ដើម្បីចូលផ្ទាំងគ្រប់គ្រង។", + "Require login": "ត្រូវការចូល", + "Required for SSL certificate and DNS configuration": "ត្រូវការសម្រាប់ SSL Certificate និងការកំណត់ DNS", + "Required for SSL certificate and server startup": "ត្រូវការសម្រាប់ SSL Certificate និងការចាប់ផ្តើមម៉ាស៊ីនមេ", + "Required to modify /etc/hosts and flush DNS cache": "ត្រូវការដើម្បីកែប្រែ /etc/hosts និង Flush DNS Cache", + "Required. A friendly label for this node.": "ចាំបាច់។ ស្លាកងាយយល់សម្រាប់ Node នេះ។", + "Required. Used as the provider prefix for model IDs.": "ចាំបាច់។ ប្រើជា Prefix អ្នកផ្តល់សេវាសម្រាប់ ID ម៉ូដែល។", + "Requires \"Workers Scripts: Edit\" permission.": "ត្រូវការសិទ្ធិ \"Workers Scripts: Edit\"។", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "ត្រូវការ Cloudflare Account ID និង Workers API Token (សិទ្ធិ Edit Workers)", + "Requires Cursor Pro account to use this feature.": "ត្រូវការគណនី Cursor Pro ដើម្បីប្រើមុខងារនេះ។", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "ត្រូវតែបានដំឡើង jcode។ ដំឡើងតាម៖ curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "ត្រូវការ Port ចេញ 7844 (TCP/UDP)។ ការតភ្ជាប់អាចចំណាយពេល 10-30 វិនាទី។", + "Reset": "កំណត់ឡើងវិញ", + "Reset Codex limit?": "កំណត់កម្រិត Codex ឡើងវិញមែនទេ?", + "Reset Password to Default": "កំណត់ពាក្យសម្ងាត់ទៅលំនាំដើម", + "Reset judge to Auto": "កំណត់ Judge ទៅស្វ័យប្រវត្តិ", + "Reset time": "ពេលវេលាកំណត់ឡើងវិញ", + "Reset to Defaults": "កំណត់ទៅលំនាំដើម", + "Reset to default": "កំណត់ទៅលំនាំដើម", + "Resources": "ធនធាន", + "Response": "ការឆ្លើយតប", + "Response Format": "ទម្រង់ការឆ្លើយតប", + "Responses": "ការឆ្លើយតប", + "Responses API": "Responses API", + "Restart": "ចាប់ផ្តើមឡើងវិញ", + "Restore model": "ស្តារម៉ូដែល", + "Resume key": "បន្ត Key", + "Retry": "ព្យាយាមម្តងទៀត", + "Risk Notice": "សេចក្តីជូនដំណឹងអំពីហានិភ័យ", + "Roo AI Assistant": "ជំនួយការ AI Roo", + "Rotate providers across requests instead of strict fallback order.": "ប្តូរវេនអ្នកផ្តល់សេវារវាងសំណើ ជំនួសឱ្យលំដាប់ Fallback តឹងរ៉ឹង។", + "Round Robin": "Round Robin", + "Round Robin — rotate": "Round Robin — ប្តូរវេន", + "Round Robin — rotates models across requests to spread load": "Round Robin — ប្តូរវេនម៉ូដែលរវាងសំណើ ដើម្បីចែកចាយបន្ទុក", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "បញ្ជូនសំណើ AI តាម Subscription, Tier តម្លៃថោក និងឥតគិតថ្លៃ ជាមួយ Fallback ដោយស្វ័យប្រវត្តិ។ Endpoint តែមួយសម្រាប់ Claude, GPT, Gemini និងផ្សេងទៀត។", + "Route Requests": "បញ្ជូនសំណើ", + "Routing Strategy": "យុទ្ធសាស្ត្របញ្ជូនផ្លូវ", + "Rows:": "ជួរដេក៖", + "Run": "ដំណើរការ", + "Run npx command to start the server instantly": "ដំណើរការ Command npx ដើម្បីចាប់ផ្តើមម៉ាស៊ីនមេភ្លាមៗ", + "Run this command in your terminal, then click": "ដំណើរការ Command នេះក្នុង terminal របស់អ្នក បន្ទាប់មកចុច", + "Running": "កំពុងដំណើរការ", + "Running on your machine": "កំពុងដំណើរការលើម៉ាស៊ីនរបស់អ្នក", + "Runtime": "Runtime", + "SSE URL": "SSE URL", + "START HERE": "ចាប់ផ្តើមទីនេះ", + "Save": "រក្សាទុក", + "Save Changes": "រក្សាទុកការផ្លាស់ប្តូរ", + "Save Config": "រក្សាទុក Config", + "Save Mappings": "រក្សាទុកការផ្គូផ្គង", + "Save auth mode": "រក្សាទុករបៀបផ្ទៀងផ្ទាត់", + "Save current Base URL and API key as a browser-local preset": "រក្សាទុក Base URL និង API Key បច្ចុប្បន្នជា Preset ក្នុង browser", + "Save this key now!": "រក្សាទុក Key នេះឥឡូវនេះ!", + "Saved": "បានរក្សាទុក", + "Saving": "កំពុងរក្សាទុក", + "Saving...": "កំពុងរក្សាទុក...", + "Scan QR to connect instantly": "ស្កេន QR ដើម្បីភ្ជាប់ភ្លាមៗ", + "Scopes": "Scopes", + "Screen sharing": "ការចែករំលែកអេក្រង់", + "Scroll down to": "រំកិលចុះក្រោមទៅ", + "Search by name or description...": "ស្វែងរកតាមឈ្មោះ ឬការពិពណ៌នា...", + "Search language...": "ស្វែងរកភាសា...", + "Search model id": "ស្វែងរក ID ម៉ូដែល", + "Search providers...": "ស្វែងរកអ្នកផ្តល់សេវា...", + "Search...": "ស្វែងរក...", + "Security": "សុវត្ថិភាព", + "Security required: ": "តម្រូវការសុវត្ថិភាព៖ ", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "ហានិភ័យសុវត្ថិភាព៖ មិនទាន់បានកំណត់ពាក្យសម្ងាត់។ អ្នកនឹងត្រូវបានស្នើឱ្យកំណត់វា នៅពេលចូលពីចម្ងាយ។", + "Select": "ជ្រើសរើស", + "Select All": "ជ្រើសទាំងអស់", + "Select Cowork Model": "ជ្រើសម៉ូដែល Cowork", + "Select Endpoint": "ជ្រើស Endpoint", + "Select Judge Model": "ជ្រើសម៉ូដែល Judge", + "Select Language": "ជ្រើសភាសា", + "Select Model": "ជ្រើសម៉ូដែល", + "Select Model for Cline": "ជ្រើសម៉ូដែលសម្រាប់ Cline", + "Select Model for Codex": "ជ្រើសម៉ូដែលសម្រាប់ Codex", + "Select Model for DeepSeek TUI": "ជ្រើសម៉ូដែលសម្រាប់ DeepSeek TUI", + "Select Model for Factory Droid": "ជ្រើសម៉ូដែលសម្រាប់ Factory Droid", + "Select Model for GitHub Copilot": "ជ្រើសម៉ូដែលសម្រាប់ GitHub Copilot", + "Select Model for Hermes Agent": "ជ្រើសម៉ូដែលសម្រាប់ Hermes Agent", + "Select Model for Kilo Code": "ជ្រើសម៉ូដែលសម្រាប់ Kilo Code", + "Select Model for Open Claw": "ជ្រើសម៉ូដែលសម្រាប់ Open Claw", + "Select Model for OpenCode": "ជ្រើសម៉ូដែលសម្រាប់ OpenCode", + "Select Model for jcode": "ជ្រើសម៉ូដែលសម្រាប់ jcode", + "Select Provider": "ជ្រើសអ្នកផ្តល់សេវា", + "Select Subagent Model for Codex": "ជ្រើសម៉ូដែល Subagent សម្រាប់ Codex", + "Select Subagent Model for OpenCode": "ជ្រើសម៉ូដែល Subagent សម្រាប់ OpenCode", + "Select a provider": "ជ្រើសអ្នកផ្តល់សេវា", + "Select all": "ជ្រើសទាំងអស់", + "Select language": "ជ្រើសភាសា", + "Select models to add": "ជ្រើសម៉ូដែលដើម្បីបន្ថែម", + "Select one or more connections, then click Proxy Action.": "ជ្រើសការតភ្ជាប់មួយ ឬច្រើន បន្ទាប់មកចុច សកម្មភាព Proxy។", + "Select to pre-fill, then edit model ID in the input": "ជ្រើសដើម្បីបំពេញជាមុន បន្ទាប់មកកែ ID ម៉ូដែលក្នុង Input", + "Select your": "ជ្រើសរើសរបស់អ្នក", + "Selected connections have mixed proxy bindings": "ការតភ្ជាប់ដែលបានជ្រើសមានការភ្ជាប់ Proxy ចម្រុះ", + "Selected only": "តែអ្វីដែលបានជ្រើស", + "Selected provider": "អ្នកផ្តល់សេវាដែលបានជ្រើស", + "Selecting None will unbind selected connections from proxy pool.": "ការជ្រើស គ្មាន នឹងដកការតភ្ជាប់ដែលបានជ្រើសចេញពី Proxy Pool។", + "Send": "ផ្ញើ", + "Send to Provider": "ផ្ញើទៅអ្នកផ្តល់សេវា", + "Sent to provider as:": "បានផ្ញើទៅអ្នកផ្តល់សេវាជា៖", + "Server": "ម៉ាស៊ីនមេ", + "Server Disconnected": "ម៉ាស៊ីនមេបានផ្តាច់ការតភ្ជាប់", + "Server off": "ម៉ាស៊ីនមេបានបិទ", + "Server running on": "ម៉ាស៊ីនមេកំពុងដំណើរការនៅ", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "សេវាកំពុងដំណើរការក្នុង terminal។ អ្នកអាចបិទទំព័រវេបនេះបាន។ ការបិទនឹងបញ្ឈប់សេវា។", + "Set Password": "កំណត់ពាក្យសម្ងាត់", + "Set a new password before accessing the dashboard remotely.": "កំណត់ពាក្យសម្ងាត់ថ្មី មុនពេលចូលផ្ទាំងគ្រប់គ្រងពីចម្ងាយ។", + "Set password": "កំណត់ពាក្យសម្ងាត់", + "Setting password for the first time. Leave current password empty or use default:": "កំពុងកំណត់ពាក្យសម្ងាត់ជាលើកដំបូង។ ទុកពាក្យសម្ងាត់បច្ចុប្បន្នឱ្យទទេ ឬប្រើលំនាំដើម៖", + "Setting up": "កំពុងរៀបចំ", + "Settings": "ការកំណត់", + "Settings applied successfully!": "បានអនុវត្តការកំណត់ដោយជោគជ័យ!", + "Settings reset successfully!": "បានកំណត់ការកំណត់ឡើងវិញដោយជោគជ័យ!", + "Setup": "រៀបចំ", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "ការរៀបចំ + បញ្ជីមុខងារទាំងអស់។ ចាប់ផ្តើមទីនេះ — គ្របដណ្តប់ Base URL, ការផ្ទៀងផ្ទាត់, ការរកឃើញម៉ូដែល និង Links ទៅមុខងារនីមួយៗ។", + "Share Endpoint": "ចែករំលែក Endpoint", + "Share URL with team members": "ចែករំលែក URL ជាមួយសមាជិកក្រុម", + "Show": "បង្ហាញ", + "Show all": "បង្ហាញទាំងអស់", + "Show key": "បង្ហាញ Key", + "Show only selected models": "បង្ហាញតែម៉ូដែលដែលបានជ្រើស", + "Showing": "កំពុងបង្ហាញ", + "Shutdown": "បិទ", + "Sign in with OIDC": "ចូលដោយ OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "ផ្ទៃជជែកសាមញ្ញសម្រាប់ទាក់ទងជាមួយម៉ូដែល AI ណាមួយពីអ្នកផ្តល់សេវាដែលបានភ្ជាប់។ ជ្រើសម៉ូដែល ហើយចាប់ផ្តើមជជែក!", + "Single": "តែមួយ", + "Single API endpoint for all major AI providers. Simplify your integration.": "API Endpoint តែមួយសម្រាប់អ្នកផ្តល់សេវា AI សំខាន់ៗទាំងអស់។ ធ្វើឱ្យការរួមបញ្ចូលរបស់អ្នកកាន់តែងាយស្រួល។", + "Some models are not responding": "ម៉ូដែលមួយចំនួនមិនកំពុងឆ្លើយតប", + "Sort Codex quotas by remaining": "រៀបលំដាប់ Codex Quotas តាមចំនួននៅសល់", + "Sort accounts by earliest quota reset time": "រៀបលំដាប់គណនីតាមពេលកំណត់ Quota ឡើងវិញដែលឆាប់បំផុត", + "Source Body": "ខ្លឹមសារសំណើដើម", + "Sourcegraph Amp coding assistant CLI": "CLI ជំនួយការសរសេរកូដ Sourcegraph Amp", + "Special reasoning/thinking tokens (fallback to output rate)": "Tokens ពិសេសសម្រាប់ការគិតហេតុផល/ការគិត (Fallback ទៅអត្រា Output)", + "Speech To Text": "សំឡេងទៅអត្ថបទ", + "Speech-to-Text": "សំឡេងទៅអត្ថបទ", + "Standard prompt tokens": "Prompt Tokens ស្តង់ដារ", + "Start DNS": "ចាប់ផ្តើម DNS", + "Start Date": "កាលបរិច្ឆេទចាប់ផ្តើម", + "Start Free": "ចាប់ផ្តើមឥតគិតថ្លៃ", + "Start Headroom": "ចាប់ផ្តើម Headroom", + "Start Headroom separately at the configured URL, then recheck.": "ចាប់ផ្តើម Headroom ដោយឡែកនៅ URL ដែលបានកំណត់ បន្ទាប់មកពិនិត្យម្តងទៀត។", + "Start MITM": "ចាប់ផ្តើម MITM", + "Start Server": "ចាប់ផ្តើមម៉ាស៊ីនមេ", + "Start Tunnel": "ចាប់ផ្តើម Tunnel", + "Start a conversation": "ចាប់ផ្តើមការសន្ទនា", + "Starting 9Router...": "កំពុងចាប់ផ្តើម 9Router...", + "Status": "ស្ថានភាព", + "Status:": "ស្ថានភាព៖", + "Step 1: Open this URL in your browser": "ជំហានទី 1៖ បើក URL នេះក្នុង browser របស់អ្នក", + "Step 2: Paste the callback URL here": "ជំហានទី 2៖ បិទភ្ជាប់ Callback URL នៅទីនេះ", + "Sticky Limit": "Sticky Limit", + "Sticky:": "Sticky៖", + "Stop": "បញ្ឈប់", + "Stop DNS": "បញ្ឈប់ DNS", + "Stop Headroom": "បញ្ឈប់ Headroom", + "Stop MITM": "បញ្ឈប់ MITM", + "Stop Server": "បញ្ឈប់ម៉ាស៊ីនមេ", + "Stopped": "បានបញ្ឈប់", + "Strict Proxy": "Strict Proxy", + "Style": "រចនាបទ", + "Subagent Model": "ម៉ូដែល Subagent", + "Sudo Password Required": "ត្រូវការពាក្យសម្ងាត់ Sudo", + "Sudo password is required": "ត្រូវការពាក្យសម្ងាត់ Sudo", + "Suggested free models (≥200k context):": "ម៉ូដែលឥតគិតថ្លៃដែលបានណែនាំ (Context ≥200k)៖", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "ឧទាហរណ៍ឈ្មោះកាត់ដែលបានណែនាំ៖ g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929។", + "Support up to 20 active apps & 50 custom domains": "គាំទ្រ Apps សកម្មរហូតដល់ 20 និង Custom Domains ចំនួន 50", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "ទម្រង់ដែលគាំទ្រ៖ protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "ធ្វើសមកាលកម្មការកំណត់រវាងឧបករណ៍ ជាមួយ Cloud Storage ជាជម្រើស។", + "System": "ប្រព័ន្ធ", + "TTFT:": "TTFT៖", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel នឹងត្រូវបញ្ឈប់។ ការចូលពីចម្ងាយតាម Tailscale URL នឹងឈប់ដំណើរការ។", + "Tailscale installed": "បានដំឡើង Tailscale", + "Tailscale is not installed. Install it to enable Funnel.": "មិនទាន់បានដំឡើង Tailscale។ សូមដំឡើងវាដើម្បីបើក Funnel។", + "Target Request": "សំណើគោលដៅ", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com។", + "Temperature": "Temperature", + "Terminal": "Terminal", + "Terms of Service": "លក្ខខណ្ឌសេវាកម្ម", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "System Prompt បែបសង្ខេប → កាត់បន្ថយ Output Tokens ប្រហែល 65% (រហូតដល់ 87%)", + "Test": "សាកល្បង", + "Test Again": "សាកល្បងម្តងទៀត", + "Test All": "សាកល្បងទាំងអស់", + "Test Example": "ឧទាហរណ៍សាកល្បង", + "Test Results": "លទ្ធផលសាកល្បង", + "Test all API Key connections": "សាកល្បងការតភ្ជាប់ API Key ទាំងអស់", + "Test all Compatible connections": "សាកល្បងការតភ្ជាប់ Compatible ទាំងអស់", + "Test all Free connections": "សាកល្បងការតភ្ជាប់ឥតគិតថ្លៃទាំងអស់", + "Test all Free provider connections": "សាកល្បងការតភ្ជាប់អ្នកផ្តល់សេវាឥតគិតថ្លៃទាំងអស់", + "Test all OAuth connections": "សាកល្បងការតភ្ជាប់ OAuth ទាំងអស់", + "Test connection": "សាកល្បងការតភ្ជាប់", + "Test model": "សាកល្បងម៉ូដែល", + "Test proxy": "សាកល្បង Proxy", + "Test proxy URL": "សាកល្បង Proxy URL", + "Testing...": "កំពុងសាកល្បង...", + "Text To Speech": "អត្ថបទទៅសំឡេង", + "Text To Speech combo": "Combo អត្ថបទទៅសំឡេង", + "Text to Image": "អត្ថបទទៅរូបភាព", + "Text to Image combo": "Combo អត្ថបទទៅរូបភាព", + "Text-to-Speech": "អត្ថបទទៅសំឡេង", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "អត្ថបទទៅរូបភាពតាម DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare Tunnel នឹងត្រូវផ្តាច់។ ការចូលពីចម្ងាយតាម Tunnel URL នឹងឈប់ដំណើរការ។", + "The proxy server has been stopped.": "ម៉ាស៊ីនមេ Proxy ត្រូវបានបញ្ឈប់។", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "សំណើត្រូវបានបំពេញភ្លាមៗដោយ OpenAI, Anthropic, Gemini ឬអ្នកផ្តល់សេវាផ្សេងទៀត។", + "The tunnel will be disconnected. Remote access will stop working.": "Tunnel នឹងត្រូវផ្តាច់។ ការចូលពីចម្ងាយនឹងឈប់ដំណើរការ។", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "Endpoint រួមសម្រាប់ការបង្កើតដោយ AI។ ភ្ជាប់ បញ្ជូនផ្លូវ និងគ្រប់គ្រងអ្នកផ្តល់សេវា AI របស់អ្នកយ៉ាងងាយស្រួល។", + "The unified interface for modern AI infrastructure": "ផ្ទៃប្រើប្រាស់រួមសម្រាប់ហេដ្ឋារចនាសម្ព័ន្ធ AI ទំនើប", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "ផ្ទៃប្រើប្រាស់រួមសម្រាប់ហេដ្ឋារចនាសម្ព័ន្ធ AI ទំនើប។ មានសុវត្ថិភាព អាចតាមដាន និងអាចពង្រីកបាន។", + "Theme": "រូបរាង", + "Thinking": "ការគិត", + "Thinking Process": "ដំណើរការគិត", + "This is the only time you will see this key. Store it securely.": "នេះជាលើកតែមួយដែលអ្នកនឹងឃើញ Key នេះ។ សូមរក្សាទុកវាឱ្យមានសុវត្ថិភាព។", + "This provider is ready to use.": "អ្នកផ្តល់សេវានេះរួចរាល់សម្រាប់ប្រើ។", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "អ្នកផ្តល់សេវានេះរួចរាល់សម្រាប់ប្រើ។ អ្នកអាចជ្រើសបញ្ជូនសំណើតាម Proxy Pool ដើម្បីរំលងកម្រិតផ្អែកលើ IP។", + "This value is write-only after saving.": "តម្លៃនេះអាចសរសេរបានតែប៉ុណ្ណោះ បន្ទាប់ពីរក្សាទុក។", + "Time": "ពេលវេលា", + "Timestamp": "ពេលវេលាកត់ត្រា", + "Timestamp:": "ពេលវេលាកត់ត្រា៖", + "To get a fresh API key, paste your browser cookie from": "ដើម្បីទទួលបាន API Key ថ្មី សូមបិទភ្ជាប់ browser cookie របស់អ្នកពី", + "Today": "ថ្ងៃនេះ", + "Toggle DNS to redirect": "បើក/បិទ DNS ដើម្បីបញ្ជូនបន្ត", + "Toggle auto-ping": "បើក/បិទ Ping ស្វ័យប្រវត្តិ", + "Token Saver": "Token Saver", + "Token Types:": "ប្រភេទ Token៖", + "Token auto-detected from Kiro IDE successfully!": "បានរកឃើញ Token ពី Kiro IDE ដោយស្វ័យប្រវត្តិ និងជោគជ័យ!", + "Token is used once for deployment and not stored.": "Token ត្រូវបានប្រើតែម្តងសម្រាប់ Deployment និងមិនត្រូវបានរក្សាទុក។", + "Token is used once for deployment, not stored. Found in Organization Settings.": "Token ត្រូវបានប្រើតែម្តងសម្រាប់ Deployment និងមិនត្រូវបានរក្សាទុក។ អាចរកឃើញក្នុងការកំណត់អង្គភាព។", + "Token will be auto-filled...": "Token នឹងត្រូវបានបំពេញដោយស្វ័យប្រវត្តិ...", + "Tokens": "Tokens", + "Tokens auto-detected from Cursor IDE successfully!": "បានរកឃើញ Tokens ពី Cursor IDE ដោយស្វ័យប្រវត្តិ និងជោគជ័យ!", + "Tokens used to create cache entries (fallback to input rate)": "Tokens ដែលប្រើដើម្បីបង្កើតធាតុ Cache (Fallback ទៅអត្រា Input)", + "Tomorrow": "ថ្ងៃស្អែក", + "Tool not found or disabled.": "រកមិនឃើញឧបករណ៍ ឬឧបករណ៍ត្រូវបានបិទ។", + "Tools": "ឧបករណ៍", + "Tools:": "ឧបករណ៍៖", + "Total Cost": "ចំណាយសរុប", + "Total Input Tokens": "Input Tokens សរុប", + "Total Models": "ម៉ូដែលសរុប", + "Total Requests": "សំណើសរុប", + "Total Tokens": "Tokens សរុប", + "Total:": "សរុប៖", + "Track and manage your API quota limits": "តាមដាន និងគ្រប់គ្រងកម្រិត API Quota របស់អ្នក", + "Track token usage, costs, and performance across all providers.": "តាមដានការប្រើ Token ចំណាយ និងប្រសិទ្ធភាពនៅទូទាំងអ្នកផ្តល់សេវាទាំងអស់។", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "បម្លែងសំឡេងទៅអត្ថបទតាម OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + "Transferring data...": "កំពុងផ្ទេរទិន្នន័យ...", + "Translator": "កម្មវិធីបកប្រែ", + "Translator Debug": "បំបាត់កំហុសកម្មវិធីបកប្រែ", + "Tried in order (top-down) or rotated when round-robin is on.": "សាកល្បងតាមលំដាប់ (ពីលើចុះក្រោម) ឬប្តូរវេននៅពេលបើក Round Robin។", + "Trust Cert": "ទុកចិត្ត Certificate", + "Trusted": "បានទុកចិត្ត", + "Try Again": "ព្យាយាមម្តងទៀត", + "Tunnel": "Tunnel", + "Tunnel connected!": "បានភ្ជាប់ Tunnel!", + "Tunnel disabled": "Tunnel បានបិទ", + "Turn off Empty": "បិទធាតុដែលអស់", + "Turn on Available": "បើកធាតុដែលនៅមាន", + "Turn request detail recording on/off globally": "បើក/បិទការកត់ត្រាព័ត៌មានលម្អិតសំណើជាសកល", + "Twitter": "Twitter", + "Type": "ប្រភេទ", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / text / HTML តាម Firecrawl, Jina, Tavily, Exa។", + "Unavailable": "មិនមាន", + "Under": "ក្រោម", + "Unified Endpoint": "Endpoint រួម", + "Unknown": "មិនស្គាល់", + "Unselect all": "ដកការជ្រើសទាំងអស់", + "Update": "ធ្វើបច្ចុប្បន្នភាព", + "Update 9Router": "ធ្វើបច្ចុប្បន្នភាព 9Router", + "Update Password": "ធ្វើបច្ចុប្បន្នភាពពាក្យសម្ងាត់", + "Update now": "ធ្វើបច្ចុប្បន្នភាពឥឡូវនេះ", + "Upstream Auth Error": "កំហុសផ្ទៀងផ្ទាត់ពី Upstream", + "Upstream Unavailable": "Upstream មិនអាចប្រើបាន", + "Usage": "ការប្រើប្រាស់", + "Usage & Analytics": "ការប្រើប្រាស់ និងការវិភាគ", + "Usage / Limit": "ការប្រើប្រាស់ / កម្រិត", + "Usage Logs": "កំណត់ហេតុការប្រើប្រាស់", + "Usage Tracking": "ការតាមដានការប្រើប្រាស់", + "Usage by API Key": "ការប្រើប្រាស់តាម API Key", + "Usage by Account": "ការប្រើប្រាស់តាមគណនី", + "Usage by Endpoint": "ការប្រើប្រាស់តាម Endpoint", + "Usage by Model": "ការប្រើប្រាស់តាមម៉ូដែល", + "Usage:": "ការប្រើប្រាស់៖", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "ប្រើ Aliases ម៉ូដែលរបស់ 9Router ដើម្បីរក្សាការផ្គូផ្គងឈ្មោះកាត់ Amp ឱ្យមានស្ថិរភាព នៅពេលអ្នកផ្តល់សេវាធ្វើបច្ចុប្បន្នភាព។", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ប្រើ Antigravity IDE និង GitHub Copilot → ជាមួយអ្នកផ្តល់សេវា/ម៉ូដែលណាមួយពី 9Router", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "ប្រើ Authentik ឬអ្នកផ្តល់សេវា OIDC ណាមួយ ដើម្បីចូលផ្ទាំងគ្រប់គ្រង។", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "ប្រើ Authentik ឬអ្នកផ្តល់សេវា OIDC ណាមួយ ដើម្បីចូលផ្ទាំងគ្រប់គ្រង។ អ្នកអាចបើកតែពាក្យសម្ងាត់ តែ OIDC ឬទាំងពីរសម្រាប់ផ្ទាំងគ្រប់គ្រង។ ការចូលប្រើ API ម៉ូដែលនៅតែប្រើ API Keys។", + "Use a GitLab OAuth application": "ប្រើ GitLab OAuth Application", + "Use a GitLab PAT with api scope": "ប្រើ GitLab PAT ដែលមាន api scope", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "ប្រើ xAI API Key ផ្ទាល់ពី console.x.ai។ វាដាច់ដោយឡែកពី Grok Build OAuth។", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "ប្រើ Proxy លើម៉ាស៊ីនសម្រាប់ ចាប់ផ្តើម/បញ្ឈប់ ឬ Docker Sidecar ខាងក្រៅដូចជា http://headroom:8787។", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "ប្រើ Kiro/CodeWhisperer API Key ដែលមានអាយុកាលវែង (Headless Auth)។", + "Use in Cursor/Cline": "ប្រើក្នុង Cursor/Cline", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "ប្រើប៊ូតុងខាងលើ ដើម្បីបន្ថែម Endpoints ដែលត្រូវគ្នាជាមួយ OpenAI ឬ Anthropic", + "Use your API from any network": "ប្រើ API របស់អ្នកពីបណ្តាញណាមួយ", + "Valid": "ត្រឹមត្រូវ", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vectors សម្រាប់ RAG / Semantic Search តាម OpenAI, Gemini, Mistral…", + "Vercel API Token": "Vercel API Token", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel បម្រើ Apps រាប់លាន — អ្នកផ្តល់សេវាមិនអាចទប់ស្កាត់ Vercel IPs ដោយមិនប៉ះពាល់ដល់ Traffic ស្របច្បាប់បានទេ", + "Verification URL": "URL ផ្ទៀងផ្ទាត់", + "Version": "កំណែ", + "Video": "វីដេអូ", + "View Codex reset credit expiry": "មើលថ្ងៃផុតកំណត់ Credit កំណត់ឡើងវិញរបស់ Codex", + "View Full Details": "មើលព័ត៌មានលម្អិតទាំងស្រុង", + "View on GitHub": "មើលនៅ GitHub", + "Visit the URL below and enter the code:": "ចូលទៅ URL ខាងក្រោម ហើយបញ្ចូល Code៖", + "Visit the login URL below and authorize:": "ចូលទៅ URL សម្រាប់ចូលខាងក្រោម ហើយផ្តល់សិទ្ធិ៖", + "Voice": "សំឡេង", + "Voice ID": "ID សំឡេង", + "Voyage AI": "Voyage AI", + "Waiting for Authorization": "កំពុងរង់ចាំការផ្តល់សិទ្ធិ", + "Waiting for authorization...": "កំពុងរង់ចាំការផ្តល់សិទ្ធិ...", + "Warning": "ការព្រមាន", + "Web Fetch": "ការទាញយកខ្លឹមសារវេប", + "Web Fetch & Search": "ការទាញយក និងស្វែងរកលើវេប", + "Web Search": "ការស្វែងរកលើវេប", + "Web Search & Fetch (Exa)": "ការស្វែងរក និងទាញយកខ្លឹមសារវេប (Exa)", + "Welcome": "សូមស្វាគមន៍", + "What is Cloudflare Relay?": "តើ Cloudflare Relay ជាអ្វី?", + "What is Deno Relay?": "តើ Deno Relay ជាអ្វី?", + "What is Vercel Relay?": "តើ Vercel Relay ជាអ្វី?", + "When": "ពេល", + "When ON, dashboard requires password. When OFF, access without login.": "នៅពេលបើក ផ្ទាំងគ្រប់គ្រងត្រូវការពាក្យសម្ងាត់។ នៅពេលបិទ អាចចូលប្រើដោយមិនចាំបាច់ចូលគណនី។", + "Windows:": "Windows៖", + "Windows: Run 9Router terminal as Administrator": "Windows៖ ដំណើរការ Terminal របស់ 9Router ជា Administrator", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows៖ ដំណើរការ Terminal (9Router) ជា Administrator ដើម្បីបើក MITM", + "Worker Name": "ឈ្មោះ Worker", + "Works on any device": "ដំណើរការលើឧបករណ៍ណាមួយ", + "Writes to": "សរសេរទៅ", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "អ្នកអាចកែតម្លៃលំនាំដើមសម្រាប់ម៉ូដែលជាក់លាក់។ កំណត់ទៅលំនាំដើមនៅពេលណាក៏បាន ដើម្បីស្តារអត្រាស្តង់ដារ។", + "Your": "គណនី", + "Your Account Name": "ឈ្មោះគណនីរបស់អ្នក", + "Your Code": "Code របស់អ្នក", + "Your Kiro account via": "គណនី Kiro របស់អ្នកតាមរយៈ", + "Your OAuth application client ID": "Client ID នៃ OAuth Application របស់អ្នក", + "Your organization's AWS IAM Identity Center URL": "AWS IAM Identity Center URL របស់អង្គភាពអ្នក", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "សំណើរបស់អ្នកចាប់ផ្តើមពីឧបករណ៍ដែលអ្នកពេញចិត្ត ឬ SDK រួមរបស់យើង។ គ្រាន់តែប្តូរ Base URL។", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "សំណើរបស់អ្នកចាប់ផ្តើមពីឧបករណ៍ដែលអ្នកពេញចិត្ត — Cursor, Claude, Copilot ឬ SDK ណាមួយដែលត្រូវគ្នាជាមួយ OpenAI។", + "account has been connected.": "របស់អ្នកត្រូវបានភ្ជាប់។", + "active": "សកម្ម", + "add OpenAI/Anthropic compatible endpoints": "បន្ថែម Endpoints ដែលត្រូវគ្នាជាមួយ OpenAI/Anthropic", + "added)": "បានបន្ថែម)", + "again after install.": "ម្តងទៀតបន្ទាប់ពីដំឡើង។", + "and click": "ហើយចុច", + "apiKey": "apiKey", + "below.": "ខាងក្រោម។", + "bound": "បានភ្ជាប់", + "chars)": "តួអក្សរ)", + "cloudflare relay": "Cloudflare Relay", + "connection": "ការតភ្ជាប់", + "connections": "ការតភ្ជាប់", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "ងងឹត", + "disabled": "បានបិទ", + "dollars per million tokens": "ដុល្លារក្នុងមួយលាន Tokens", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "ឧ. CwhRBWXzGAHq8TQ4Fs17", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "ឧទាហរណ៍៖ សំឡេងកក់ក្តៅ និងទន់ភ្លន់ និយាយយឺតៗដោយសង្កត់សំឡេងបែបអង់គ្លេស", + "e.g. claude-opus-4-5": "ឧ. claude-opus-4-5", + "e.g. my-model-id": "ឧ. my-model-id", + "e.g. tts-1-hd": "ឧ. tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "ឧ. voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "ឧ. Production API, Dev Environment", + "every request bills all panel models + the judge": "សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + Judge", + "export": "នាំចេញ", + "failed": "បរាជ័យ", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → កាត់បន្ថយ Input Tokens 60-90%", + "h ago": "ម៉ោងមុន", + "has been connected.": "ត្រូវបានភ្ជាប់។", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "ការផ្ទៀងផ្ទាត់ iFlow ដោយ Cookie", + "import": "នាំចូល", + "inactive": "អសកម្ម", + "jcode - Manual Configuration": "jcode - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "jcode CLI not detected locally": "រកមិនឃើញ jcode CLI នៅលើម៉ាស៊ីន", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode ជា Coding Agent ដែលបង្កើតដោយ Rust មានអង្គចងចាំតាមន័យ ក្រុម Agent ច្រើន និងប្រសិទ្ធភាពខ្ពស់បំផុត (RAM 27.8 MB, Boot 14ms)។", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "ភ្លឺ", + "m ago": "នាទីមុន", + "macOS / Linux / Windows:": "macOS / Linux / Windows៖", + "macOS / Linux:": "macOS / Linux៖", + "macOS/Linux:": "macOS/Linux៖", + "more": "បន្ថែម", + "more providers": "អ្នកផ្តល់សេវាបន្ថែម", + "ms / Total": "ms / សរុប", + "name|apiKey": "name|apiKey", + "no_proxy:": "no_proxy៖", + "not detected locally": "រកមិនឃើញនៅលើម៉ាស៊ីន", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "open http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "Context ជាជម្រើសដើម្បីបង្កើនភាពត្រឹមត្រូវ", + "or VS Code extension marketplace.": "ឬទីផ្សារ Extension របស់ VS Code។", + "or just": "ឬគ្រាន់តែ", + "passed": "បានជោគជ័យ", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក Judge សំយោគជាចម្លើយតែមួយ។ គុណភាពល្អបំផុត ប៉ុន្តែចំណាយខ្ពស់បំផុត៖ សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + Judge (N+1 Calls)", + "records, batches every": "Records, បាច់រៀងរាល់", + "requests, max": "សំណើ, អតិបរមា", + "rotates models across requests to spread load": "ប្តូរវេនម៉ូដែលរវាងសំណើ ដើម្បីចែកចាយបន្ទុក", + "s)": "វិនាទី)", + "s...": "វិនាទី...", + "seconds...": "វិនាទី...", + "sends image/PDF/audio requests to a model that supports them first": "ផ្ញើសំណើរូបភាព/PDF/សំឡេង ទៅម៉ូដែលដែលគាំទ្រពួកវាជាមុន", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router (លំនាំដើម)", + "system": "ប្រព័ន្ធ", + "tested": "បានសាកល្បង", + "the database.": "មូលដ្ឋានទិន្នន័យ។", + "to apply changes": "ដើម្បីអនុវត្តការផ្លាស់ប្តូរ", + "to verify.": "ដើម្បីផ្ទៀងផ្ទាត់។", + "traffic through 9Router via MITM.": "Traffic តាម 9Router ដោយ MITM។", + "tries models in order (next on failure)": "សាកល្បងម៉ូដែលតាមលំដាប់ (ទៅបន្ទាប់នៅពេលបរាជ័យ)", + "unknown": "មិនស្គាល់", + "v1.0 is now live": "v1.0 ឥឡូវនេះបានដាក់ឱ្យប្រើ", + "vercel relay": "Vercel Relay", + "yet.": "នៅឡើយ។", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© 2025 9Router។ រក្សាសិទ្ធិគ្រប់យ៉ាង។", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក Judge សំយោគជាចម្លើយតែមួយ។ គុណភាពល្អបំផុត ប៉ុន្តែចំណាយខ្ពស់បំផុត៖ សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + Judge (N+1 Calls)", + "— rotates models across requests to spread load": "— ប្តូរវេនម៉ូដែលរវាងសំណើ ដើម្បីចែកចាយបន្ទុក", + "— sends image/PDF/audio requests to a model that supports them first": "— ផ្ញើសំណើរូបភាព/PDF/សំឡេង ទៅម៉ូដែលដែលគាំទ្រពួកវាជាមុន", + "— tries models in order (next on failure)": "— សាកល្បងម៉ូដែលតាមលំដាប់ (ទៅបន្ទាប់នៅពេលបរាជ័យ)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ គោលដៅ", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ បើក DNS ដើម្បីកែសម្រួលការផ្គូផ្គងម៉ូដែល", + "⚠️ Local plugins run as subprocess via": "⚠️ Plugins លើម៉ាស៊ីនដំណើរការជា Subprocess តាមរយៈ", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ចាប់ HTTPS Traffic របស់ឧបករណ៍ IDE (Antigravity, GitHub Copilot, Kiro) តាម Local CA ដើម្បីបញ្ជូនសំណើទៅអ្នកផ្តល់សេវារបស់អ្នក។ អាចរំលោភ ToS → គណនីត្រូវបានហាមឃាត់។ ប្រើដោយទទួលខុសត្រូវលើហានិភ័យដោយខ្លួនឯង។", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ សេចក្តីជូនដំណឹងអំពីហានិភ័យ៖ អ្នកផ្តល់សេវានេះប្រើ Subscription/OAuth Session ដែលមិនមានអាជ្ញាបណ្ណផ្លូវការសម្រាប់ប្រើជា Proxy/Router។ គណនីអាចត្រូវបានដាក់កម្រិត ឬហាមឃាត់។ ប្រើដោយទទួលខុសត្រូវលើហានិភ័យដោយខ្លួនឯង។", + "✓ Confirm Add": "✓ បញ្ជាក់ការបន្ថែម", + "📝 Configure providers in dashboard or use environment variables": "📝 កំណត់អ្នកផ្តល់សេវាក្នុងផ្ទាំងគ្រប់គ្រង ឬប្រើ Environment Variables", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 ត្រូវការ OAuth។ បន្ថែមឥឡូវ ហើយផ្ទៀងផ្ទាត់បន្ទាប់ពីចុច អនុវត្ត; បញ្ជីឧបករណ៍នឹងត្រូវបានរកឃើញបន្ទាប់ពីការតភ្ជាប់លើកដំបូង។" +} diff --git a/public/i18n/literals/ko.json b/public/i18n/literals/ko.json index 1edb094e..2ca0fc19 100644 --- a/public/i18n/literals/ko.json +++ b/public/i18n/literals/ko.json @@ -12,6 +12,7 @@ "Logout": "로그아웃", "Login": "로그인", "Providers": "제공자", + "Style": "스타일", "Usage": "통계", "API Key": "API 키", "Connected": "연결됨", @@ -176,6 +177,7 @@ "How it works:": "작동 방식:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE 요청 → localhost:443로 DNS 리디렉션 → MITM 프록시가 가로챔 → 9Router → Antigravity/Copilot으로 응답", "No API keys — create one in Keys page": "API 키 없음 — 키 페이지에서 만들기", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "예: 따뜻하고 부드러운 목소리로 영국식 억양을 쓰며 천천히 말하기", "sk_9router (default)": "sk_9router (기본값)", "Server started": "서버 시작됨", "Failed to start server": "서버 시작 실패", diff --git a/public/i18n/literals/nl.json b/public/i18n/literals/nl.json index 2eab85ba..9fb47cda 100644 --- a/public/i18n/literals/nl.json +++ b/public/i18n/literals/nl.json @@ -12,6 +12,7 @@ "Logout": "Afmelden", "Login": "Aanmelden", "Providers": "Providers", + "Style": "Stijl", "Usage": "Statistieken", "API Key": "API-sleutel", "Connected": "Verbonden", @@ -176,6 +177,7 @@ "How it works:": "Hoe het werkt:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-aanvraag → DNS-omleiding naar localhost:443 → MITM-proxy onderschept → 9Router → antwoord naar Antigravity/Copilot", "No API keys — create one in Keys page": "Geen API-sleutels — maak er één aan op de pagina Sleutels", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "bijv.: een warme, zachte stem, die langzaam praat met een Brits accent", "sk_9router (default)": "sk_9router (standaard)", "Server started": "Server gestart", "Failed to start server": "Server starten mislukt", diff --git a/public/i18n/literals/no.json b/public/i18n/literals/no.json index e3f52155..50e84505 100644 --- a/public/i18n/literals/no.json +++ b/public/i18n/literals/no.json @@ -12,6 +12,7 @@ "Logout": "Logg ut", "Login": "Logg inn", "Providers": "Leverandører", + "Style": "Stil", "Usage": "Bruksstatistikk", "API Key": "API-nøkkel", "Connected": "Tilkoblet", @@ -176,6 +177,7 @@ "How it works:": "Slik fungerer det:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-forespørsel → DNS-omdirigering til localhost:443 → MITM-proxy avlytt → 9Router → svar til Antigravity/Copilot", "No API keys — create one in Keys page": "Ingen API-nøkler — lag en på Keys-siden", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "f.eks.: en varm, myk stemme, som snakker sakte med britisk aksent", "sk_9router (default)": "sk_9router (standard)", "Server started": "Server startet", "Failed to start server": "Klarte ikke å starte server", diff --git a/public/i18n/literals/pl.json b/public/i18n/literals/pl.json index f4b62a67..3840be97 100644 --- a/public/i18n/literals/pl.json +++ b/public/i18n/literals/pl.json @@ -12,6 +12,7 @@ "Logout": "Wyloguj się", "Login": "Zaloguj się", "Providers": "Dostawcy", + "Style": "Styl", "Usage": "Statystyka", "API Key": "Klucz API", "Connected": "Połączony", @@ -176,6 +177,7 @@ "How it works:": "Jak to działa:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Żądanie Antigravity/Copilot IDE → Przekierowanie DNS na localhost:443 → Serwer proxy MITM przechwytuje → 9Router → odpowiedź do Antigravity/Copilot", "No API keys — create one in Keys page": "Brak kluczy API — utwórz jeden na stronie Klucze", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "np.: ciepły, delikatny głos, mówiący powoli z brytyjskim akcentem", "sk_9router (default)": "sk_9router (domyślnie)", "Server started": "Serwer uruchomiony", "Failed to start server": "Nie udało się uruchomić serwera", diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index 6edba2e7..a4186636 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -1,195 +1,990 @@ { - "Cancel": "Cancelar", - "Delete": "Excluir", - "Edit": "Editar", - "Save": "Salvar", - "Close": "Fechar", - "Add": "Adicionar", - "Remove": "Remover", - "Settings": "Configurações", - "Profile": "Perfil", - "Dashboard": "Painel de controle", - "Logout": "Sair", - "Login": "Conectar", - "Providers": "Provedores", - "Usage": "Estatísticas", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(PXPIPE)": "(PXPIPE)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "9Router (Entry)": "9Router (Inicial)", + "API": "API", + "API Endpoint": "Endpoint da API", "API Key": "Chave API", - "Connected": "Conectado", - "Disconnected": "Desconectado", - "Active": "Ativo", - "Inactive": "Inativo", - "Success": "Sucesso", - "Failed": "Falha", - "Error": "Erro", - "Warning": "Aviso", - "Info": "Informações", - "Loading": "Carregando", - "Search": "Pesquisar", - "Filter": "Filtrar", - "Sort": "Classificar", - "Export": "Exportar", - "Import": "Importar", - "Refresh": "Atualizar", - "Back": "Voltar", - "Next": "Próximo", - "Previous": "Anterior", - "Submit": "Enviar", - "Confirm": "Confirmar", - "Yes": "Sim", - "No": "Não", - "OK": "OK", - "Apply": "Aplicar", - "Reset": "Redefinir", - "Clear": "Limpar", - "Select": "Selecionar", - "Upload": "Enviar", - "Download": "Baixar", - "Copy": "Copiar", - "Paste": "Colar", - "Cut": "Cortar", - "Undo": "Desfazer", - "Redo": "Refazer", - "Name": "Nome", - "Description": "Descrição", - "Status": "Status", - "Type": "Tipo", - "Date": "Data", - "Time": "Hora", - "Created": "Criado", - "Updated": "Atualizado", - "Actions": "Ações", - "Details": "Detalhes", - "View": "Visualizar", - "New": "Novo", - "Total": "Total", - "Count": "Contagem", - "Price": "Preço", - "Cost": "Custo", - "Free": "Gratuito", - "Paid": "Pago", - "Enable": "Ativar", - "Disable": "Desativar", - "Enabled": "Ativado", - "Disabled": "Desativado", - "Online": "Online", - "Offline": "Offline", - "Available": "Disponível", - "Unavailable": "Indisponível", - "Required": "Obrigatório", - "Optional": "Opcional", - "Default": "Padrão", - "Custom": "Personalizado", - "Advanced": "Avançado", - "Basic": "Básico", - "Help": "Ajuda", - "Support": "Suporte", - "Documentation": "Documentação", - "Version": "Versão", - "Language": "Idioma", - "Theme": "Tema", - "Light": "Claro", - "Dark": "Escuro", - "Auto": "Automático", - "Endpoint": "Ponto de extremidade", - "Combos": "Combinações", - "Quota Tracker": "Rastreador de cota", - "MITM": "MITM", - "CLI Tools": "Ferramentas CLI", - "Console Log": "Log do console", - "System": "Sistema", - "Debug": "Depuração", - "Shutdown": "Desligar", - "Close Proxy": "Fechar proxy", - "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", - "Server Disconnected": "Servidor desconectado", - "The proxy server has been stopped.": "O servidor proxy foi parado.", - "Reload Page": "Recarregar página", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", - "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", - "Model combos with fallback": "Combinações de modelos com fallback", - "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", - "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", - "Configure CLI tools": "Configurar ferramentas CLI", + "API Key (for Check)": "Chave API (para Verificação)", + "API Key Created": "Chave API Criada", + "API Keys": "Chaves de API", + "API Token": "Token de API", + "API Tokens": "Tokens de API", + "API Type": "Tipo de API", + "API Version": "Versão da API", "API endpoint configuration": "Configuração do ponto de extremidade da API", - "Manage your preferences": "Gerenciar suas preferências", - "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", - "Live server console output": "Saída do console do servidor ao vivo", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "Região AWS", + "AWS region for the key (default: us-east-1)": "Região AWS para a chave (padrão: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)", + "About": "Sobre", + "Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...", + "Account": "Conta", + "Account ID": "ID da Conta", + "Account Resources": "Recursos da Conta", + "Accounts per page": "Contas por página", + "Action": "Ação", + "Actions": "Ações", + "Activate": "Ativar", + "Active": "Ativo", + "Active All": "Ativar Todos", + "Active:": "Ativo:", + "Add": "Adicionar", + "Add API Key": "Adicionar Chave de API", + "Add Anthropic Compatible": "Adicionar Compatível com Anthropic", + "Add Connection": "Adicionar Conexão", + "Add Custom Embedding": "Adicionar Embedding Personalizado", + "Add Custom MCP": "Adicionar MCP Personalizado", + "Add Custom Model": "Adicionar Modelo Personalizado", + "Add Model": "Adicionar Modelo", + "Add Model for GitHub Copilot": "Adicionar Modelo para GitHub Copilot", + "Add Model for OpenCode": "Adicionar Modelo para OpenCode", + "Add Model to Combo": "Adicionar Modelo ao Combo", + "Add New Provider": "Adicionar Novo Provedor", + "Add OpenAI Compatible": "Adicionar Compatível com OpenAI", + "Add Provider": "Adicionar Provedor", + "Add Proxy Pool": "Adicionar Pool de Proxy", + "Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.", + "Add connection using browser cookie": "Adicionar conexão usando cookie do navegador", + "Add model": "Adicionar modelo", + "Advanced": "Avançado", + "After PXPIPE": "Após PXPIPE", + "After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.", + "After installation, run": "Após a instalação, execute", + "All": "Todos", + "All AI Providers": "Todos os Provedores de IA", + "All Providers": "Todos os Provedores", + "All data stored on your machine": "Todos os dados armazenados em sua máquina", + "All models are responding normally.": "Todos os modelos estão respondendo normalmente.", + "All providers": "Todos os provedores", + "Allow dashboard access via tunnel": "Permitir acesso ao painel via túnel", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "App Name": "Nome do App", + "Appearance": "Aparência", + "Appends (level) suffix to copied model names": "Adiciona sufixo (nível) aos nomes de modelo copiados", + "Apply": "Aplicar", + "Apply Proxy": "Aplicar Proxy", + "Applying...": "Aplicando...", + "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Audio File": "Arquivo de Áudio", + "Auth Mode": "Modo de Autenticação", + "Authenticate": "Autenticar", + "Authentication Successful": "Autenticação Bem-sucedida", + "Authentication Successful!": "Autenticação Bem-sucedida!", + "Authless": "Sem Autenticação", + "Authorization Successful!": "Autorização Bem-sucedida!", + "Authorize": "Autorizar", + "Auto": "Automático", + "Auto (by priority)": "Auto (por prioridade)", + "Auto Refresh (3s)": "Atualização Automática (3s)", + "Auto-detect": "Detecção Automática", + "Auto-detecting token...": "Detectando token automaticamente...", + "Auto-detecting tokens...": "Detectando tokens automaticamente...", + "Auto-ping": "Ping Automático", + "Auto-refresh": "Atualização automática", + "Available": "Disponível", + "Azure Endpoint": "Endpoint Azure", + "Azure OpenAI Configuration": "Configuração Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "Voltar", + "Back to CLI Tools": "Voltar para Ferramentas CLI", + "Back to Providers": "Voltar para Provedores", + "Base URL": "URL Base", + "Basic": "Básico", + "Batch Import": "Importar em Lote", + "Batch Import Proxies": "Importar Proxies em Lote", + "Batch Size": "Tamanho do lote", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar", + "Binary File": "Arquivo Binário", + "Browse MCP Marketplace": "Explorar Marketplace MCP", + "Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.", + "Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)", + "Bulk Add": "Adição em Massa", + "Bypassed": "Ignorado", + "CLI Tools": "Ferramentas CLI", + "Cache Creation": "Criação de Cache", + "Cache Creation:": "Criação de Cache:", + "Cached": "Em Cache", + "Cached Tokens": "Tokens em Cache", + "Cached Tokens:": "Tokens em Cache:", + "Cached:": "Em Cache:", + "Calls per account before switching": "Chamadas por conta antes de alternar", + "Calls per combo model before switching": "Chamadas por modelo de combo antes de alternar", + "Cancel": "Cancelar", + "Capacity auto-switch": "Troca automática de capacidade", + "Cert": "Certificado", + "Change Log": "Registro de Alterações", + "Changelog": "Registro de Alterações", + "Chat": "Chat", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.", + "Check again": "Verificar novamente", + "Checking Claude CLI...": "Verificando Claude CLI...", + "Checking Claude Cowork...": "Verificando Claude Cowork...", + "Checking Cline...": "Verificando Cline...", + "Checking Codex CLI...": "Verificando Codex CLI...", + "Checking Copilot config...": "Verificando configuração do Copilot...", + "Checking DeepSeek TUI...": "Verificando DeepSeek TUI...", + "Checking Factory Droid CLI...": "Verificando Factory Droid CLI...", + "Checking Grok Build...": "Verificando Grok Build...", + "Checking Hermes Agent...": "Verificando Hermes Agent...", + "Checking Kilo Code...": "Verificando Kilo Code...", + "Checking Open Claw CLI...": "Verificando Open Claw CLI...", + "Checking OpenCode CLI...": "Verificando OpenCode CLI...", + "Checking jcode CLI...": "Verificando jcode CLI...", + "Checking...": "Verificando...", + "Checking…": "Verificando…", + "Choose how to authenticate with GitLab Duo:": "Escolha como autenticar com GitLab Duo:", + "Choose your authentication method:": "Escolha seu método de autenticação:", + "Claude CLI - Manual Configuration": "Claude CLI - Configuração Manual", + "Claude CLI not detected locally": "Claude CLI não detectado localmente", + "Claude Cowork - Manual Configuration": "Claude Cowork - Configuração Manual", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (modo Cowork) não detectado", + "Clear": "Limpar", + "Clear (inherit main model for subagents)": "Limpar (herdar modelo principal para subagentes)", + "Clear (will use main model)": "Limpar (usará o modelo principal)", + "Clear Filters": "Limpar Filtros", + "Clear search": "Limpar pesquisa", + "Click": "Clique", + "Click a model to set/clear active": "Clique em um modelo para ativar/desativar", + "Click to add, click again to remove.": "Clique para adicionar, clique novamente para remover.", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", + "Click to edit": "Clique para editar", + "Client ID": "ID do Cliente", + "Client Secret": "Segredo do Cliente", + "Client Secret (optional for PKCE)": "Segredo do Cliente (opcional para PKCE)", + "Cline - Manual Configuration": "Cline - Configuração Manual", + "Cline not detected locally": "Cline não detectado localmente", + "Clone and run locally": "Clone e execute localmente", + "Close": "Fechar", + "Close Proxy": "Fechar proxy", + "Close menu": "Fechar menu", + "Close provider filter": "Fechar filtro de provedor", + "Close reset credit expiry modal": "Fechar redefinição de crédito", + "Close test results": "Fechar resultados de teste", + "Closing in": "Fechando em", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Túnel Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual", + "Codex CLI not detected locally": "Codex CLI não detectado localmente", + "Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex", + "Combo Name": "Nome do Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Limite Fixo do Combo", + "Combos": "Combinações", + "Coming soon...": "Em breve...", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", + "Company": "Empresa", + "Compress LLM output": "Comprimir saída do LLM", + "Compress context": "Comprimir contexto", + "Compress prompts as images": "Comprimir prompts como imagens", + "Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo", + "Compress tool output": "Comprimir saída da ferramenta", + "Compressed": "Comprimido", + "Compressed (est.)": "Comprimido (est.)", + "Compression extras": "Extras de compressão", + "Configure CLI tools": "Configurar ferramentas CLI", + "Configure a new AI provider to use with your applications.": "Configure um novo provedor de IA para usar com suas aplicações.", + "Configure pricing rates for cost tracking and calculations": "Configure taxas de preço para rastreamento e cálculo de custos", + "Configure providers and API keys via web interface": "Configure provedores e chaves de API via interface web", + "Confirm": "Confirmar", + "Confirm New Password": "Confirmar nova senha", + "Confirm Password": "Confirmar Senha", + "Confirm new password": "Confirme a nova senha", + "Connect": "Conectar", + "Connect Cursor IDE": "Conectar Cursor IDE", + "Connect GitLab Duo": "Conectar GitLab Duo", + "Connect Kiro": "Conectar Kiro", + "Connect with OAuth2": "Conectar com OAuth2", + "Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.", + "Connected": "Conectado", + "Connected Successfully!": "Conectado com Sucesso!", + "Connection": "Conexão", + "Connection Failed": "Falha na Conexão", + "Connections": "Conexões", + "Console Log": "Log do console", + "Content": "Conteúdo", + "Continue": "Continuar", + "Continue to summary": "Continuar para resumo", + "Continue with GitHub": "Continuar com GitHub", + "Continue with Google": "Continuar com Google", + "Cookie": "Cookie", + "Cookie String": "String de Cookie", + "Copied": "Copiado", + "Copied!": "Copiado!", + "Copy": "Copiar", + "Copy This URL": "Copiar Esta URL", + "Copy combo name": "Copiar nome do combo", + "Copy install command": "Copiar comando de instalação", + "Copy link": "Copiar link", + "Copy the entire cookie string (must include BXAuth)": "Copie a string completa do cookie (deve incluir BXAuth)", + "Cost": "Custo", + "Cost Calculation:": "Cálculo de Custo:", + "Costs": "Custos", + "Could not read Cursor database automatically.": "Não foi possível ler o banco de dados do Cursor automaticamente.", + "Count": "Contagem", + "Create": "Criar", + "Create API Key": "Criar Chave API", + "Create Combo": "Criar Combo", + "Create Cowork Combo": "Criar Combo Cowork", + "Create Key": "Criar Chave", + "Create Provider": "Criar Provedor", + "Create Token": "Criar Token", + "Create a": "Criar um(a)", + "Create a proxy pool entry, then assign it to connections.": "Crie uma entrada de pool de proxy e atribua-a às conexões.", "Create model combos with fallback support": "Crie combinações de modelos com suporte a fallback", - "Local Mode": "Modo local", - "Running on your machine": "Executando em sua máquina", + "Create your first API key to get started": "Crie sua primeira chave de API para começar", + "Created": "Criado", + "Current": "Atual", + "Current Password": "Senha atual", + "Current Pricing Overview": "Visão Geral de Preços Atual", + "Current password": "Senha atual", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE não detectado. Cole seus tokens manualmente.", + "Custom": "Personalizado", + "Custom Pricing:": "Preço Personalizado:", + "Custom Token": "Token Personalizado", + "Custom accounts per page": "Contas personalizadas por página", + "Custom...": "Personalizado...", + "Cut": "Cortar", + "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", + "Cycle through providers in combos instead of always starting with first": "Percorrer provedores em combos ao invés de sempre começar pelo primeiro", + "DNS off": "DNS desligado", + "Dark": "Escuro", + "Dashboard": "Painel", + "Data Location:": "Localização dos Dados:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "Os dados fluem perfeitamente da sua aplicação através de nossa camada de roteamento inteligente para o melhor provedor.", "Database Location": "Localização do banco de dados", - "Download Backup": "Baixar backup", - "Import Backup": "Importar backup", "Database backup downloaded": "Backup do banco de dados baixado", "Database imported successfully": "Banco de dados importado com sucesso", - "Security": "Segurança", - "Require login": "Exigir login", - "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", - "Current Password": "Senha atual", - "Enter current password": "Digite a senha atual", - "New Password": "Nova senha", - "Enter new password": "Digite a nova senha", - "Confirm New Password": "Confirmar nova senha", - "Confirm new password": "Confirme a nova senha", - "Update Password": "Atualizar senha", - "Set Password": "Definir senha", - "Password updated successfully": "Senha atualizada com sucesso", - "Passwords do not match": "As senhas não correspondem", - "Routing Strategy": "Estratégia de roteamento", - "Round Robin": "Round Robin", - "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", - "Sticky Limit": "Limite pegajoso", - "Calls per account before switching": "Chamadas por conta antes de alternar", - "Network": "Rede", - "Outbound Proxy": "Proxy de saída", - "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", - "Proxy URL": "URL do proxy", - "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", - "No Proxy": "Sem proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", - "Test proxy URL": "Testar URL do proxy", - "Proxy settings applied": "Configurações de proxy aplicadas", - "Proxy enabled": "Proxy ativado", - "Proxy disabled": "Proxy desativado", - "Proxy test OK": "Teste de proxy OK", - "Proxy test failed": "Falha no teste de proxy", - "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", - "Observability": "Observabilidade", + "Date": "Data", + "DateTime": "Data e Hora", + "Deactivate": "Desativar", + "Debug": "Depuração", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual", + "DeepSeek TUI not detected locally": "DeepSeek TUI não detectado localmente", + "Default": "Padrão", + "Default Model": "Modelo Padrão", + "Delete": "Excluir", + "Delete connection": "Excluir conexão", + "Delete saved endpoint": "Excluir endpoint salvo", + "Delete selected preset": "Excluir predefinição selecionada", + "Deno Deploy API Token": "Token de API Deno Deploy", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 roda em uma rede edge global de alto desempenho", + "Deno Relay": "Deno Relay", + "Deploy Cloudflare Relay": "Implantar Relay Cloudflare", + "Deploy Deno Relay": "Implantar Relay Deno", + "Deploy Relay": "Implantar Relay", + "Deploy Vercel Relay": "Implantar Relay Vercel", + "Deploy multiple relays for maximum IP diversity": "Implantar múltiplos relays para máxima diversidade de IP", + "Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP", + "Deployment Name": "Nome da Implantação", + "Description": "Descrição", + "Detail": "Detalhe", + "Details": "Detalhes", + "Dimensions": "Dimensões", + "Disable": "Desativar", + "Disable All": "Desativar Todos", + "Disable Tailscale": "Desativar Tailscale", + "Disable Tunnel": "Desativar Túnel", + "Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual", + "Disable this model": "Desativar este modelo", + "Disabled": "Desativado", + "Disconnected": "Desconectado", + "Dismiss notification": "Dispensar notificação", + "Display Name": "Nome de Exibição", + "Display language": "Idioma de exibição", + "Docs": "Documentação", + "Documentation": "Documentação", + "Donate": "Doar", + "Done": "Concluído", + "Download": "Baixar", + "Download Backup": "Baixar backup", + "Drag to reorder": "Arraste para reordenar", + "Duration": "Duração", + "Edit": "Editar", + "Edit Connection": "Editar Conexão", + "Edit Pricing": "Editar Preços", + "Edit connection": "Editar conexão", + "Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:", + "Email": "E-mail", + "Embeddings": "Embeddings", + "Enable": "Ativar", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", "Enable Observability": "Ativar observabilidade", - "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Enable Tunnel": "Ativar Túnel", + "Enable connections that still have quota on the current page": "Ativar conexões que ainda têm cota na página atual", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", + "Enabled": "Ativado", + "End Date": "Data Final", + "Endpoint": "Ponto de extremidade", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API.", + "Enter current password": "Digite a senha atual", + "Enter model id": "Digite o ID do modelo", + "Enter model id (provider-specific)": "Digite o ID do modelo (específico do provedor)", + "Enter new API key": "Digite a nova chave de API", + "Enter new password": "Digite a nova senha", + "Enter password": "Digite a senha", + "Enter sudo password": "Digite a senha sudo", + "Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.", + "Enter your API key": "Digite sua chave de API", + "Enter your password to access the dashboard": "Digite sua senha para acessar o painel", + "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "EnvironmentVariables": "Variáveis de Ambiente", + "Error": "Erro", + "Error updating setting:": "Erro ao atualizar configuração:", + "Est. Cost": "Custo Est.", + "Estimated, not actual billing": "Estimado, não é a cobrança real", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "Tudo que você precisa para gerenciar sua infraestrutura de IA em um só lugar, projetado para escala.", + "Exa MCP": "Exa MCP", + "Example": "Exemplo", + "Expires At": "Expira Em", + "Expiring first": "Expirando primeiro", + "Export": "Exportar", + "External": "Externo", + "FREE": "GRATUITO", + "Factory Droid - Manual Configuration": "Factory Droid - Configuração Manual", + "Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente", + "Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.", + "Failed": "Falha", + "Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.", + "Failed to start proxy": "Falha ao iniciar proxy", + "Failed to start server": "Falha ao iniciar o servidor", + "Failed to stop server": "Falha ao parar o servidor", + "Fallback": "Fallback", + "Features": "Recursos", + "Filter": "Filtrar", + "Filter accounts by status": "Filtrar contas por status", + "Filter naming": "Filtrar nomenclatura", + "Filter naming requests": "Solicitações de filtro de nomenclatura", + "Filter quota providers": "Filtrar provedores de cota", + "Filters": "Filtros", + "Find MCPs →": "Encontrar MCPs →", + "First Page": "Primeira Página", + "Flush Interval (ms)": "Intervalo de liberação (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "Para usuários empresariais com AWS IAM Identity Center personalizado.", + "Format": "Formato", + "Found on the right side of the Cloudflare dashboard overview page.": "Encontrado no lado direito da página de visão geral do painel Cloudflare.", + "Free": "Gratuito", + "Free Tier Providers": "Provedores Gratuitos", + "Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge", + "Fresh API key obtained": "Nova chave de API obtida", + "Fusion": "Fusão", + "General": "Geral", + "Get 9Remote": "Obter 9Remote", + "Get API Key": "Obter Chave de API", + "Get API Key →": "Obter Chave de API →", + "Get Started": "Começar", + "Get Started in 30 Seconds": "Comece em 30 Segundos", + "Get started": "Começar", + "Get token →": "Obter token →", + "GitHub": "GitHub", + "GitHub Account": "Conta GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - Configuração Manual", + "GitLab Access Tokens": "Tokens de Acesso GitLab", + "GitLab Applications": "Aplicativos GitLab", + "GitLab Base URL": "URL Base do GitLab", + "Go to": "Ir para", + "Google Account": "Conta Google", + "Granted At": "Concedido Em", + "Grok Build - Manual Configuration": "Grok Build - Configuração Manual", + "Grok Build not detected locally": "Grok Build não detectado localmente", + "Group models under one name, then pick a strategy per combo:": "Agrupe modelos sob um nome e escolha uma estratégia por combo:", + "Headroom": "Headroom", + "Headroom proxy is reachable. You can enable the token saver.": "Proxy Headroom está acessível. Você pode ativar o economizador de tokens.", + "Health check": "Verificação de integridade", + "Healthy": "Saudável", + "Help": "Ajuda", + "Hermes Agent - Manual Configuration": "Hermes Agent - Configuração Manual", + "Hermes Agent not detected locally": "Hermes Agent não detectado localmente", + "Hidden:": "Oculto:", + "Hide this quota row": "Ocultar esta linha de cota", + "High performance global routing and IP masking via Cloudflare Workers": "Roteamento global de alto desempenho e mascaramento de IP via Cloudflare Workers", + "History": "Histórico", + "How 9Router Works": "Como o 9Router Funciona", + "How Pricing Works": "Como Funcionam os Preços", + "How it Works": "Como Funciona", + "How it works:": "Como funciona:", + "How to generate API token:": "Como gerar o token de API:", + "How to generate your API Token:": "Como gerar seu Token de API:", + "How to get cookie:": "Como obter o cookie:", + "ID:": "ID:", + "Image Generation": "Geração de Imagens", + "Images": "Imagens", + "Import": "Importar", + "Import Backup": "Importar backup", + "Import CLIProxyAPI JSON": "Importar JSON CLIProxyAPI", + "Import Token": "Importar Token", + "In / Out": "Entrada / Saída", + "Inactive": "Inativo", + "Inactive pools are ignored by runtime resolution.": "Pools inativos são ignorados pela resolução em tempo de execução.", + "Info": "Informações", + "Initializing...": "Inicializando...", + "Input": "Entrada", + "Input Tokens": "Tokens de Entrada", + "Input Tokens:": "Tokens de Entrada:", + "Input:": "Entrada:", + "Install": "Instalar", + "Install 9Router": "Instalar 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "Instale o 9Router, configure seus provedores via painel web e comece a rotear requisições de IA.", + "Install Chrome extension": "Instalar extensão Chrome", + "Install Cline VS Code extension or CLI from": "Instalar extensão Cline VS Code ou CLI de", + "Install Kilo Code from": "Instalar Kilo Code de", + "Install Tailscale": "Instalar Tailscale", + "Install [ml]": "Instalar [ml]", + "Install command:": "Comando de instalação:", + "Install failed": "Falha na instalação", + "Install jcode to enable automatic configuration:": "Instale jcode para ativar a configuração automática:", + "Install then click Start:": "Instale e clique em Iniciar:", + "Install via npm:": "Instalar via npm:", + "Installation Guide": "Guia de Instalação", + "Installing Tailscale...": "Instalando Tailscale...", + "Installing…": "Instalando…", + "Interactive diagram visible on desktop": "Diagrama interativo visível no desktop", + "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", + "Invalid": "Inválido", + "Issuer URL": "URL do Emissor", + "JSON Response": "Resposta JSON", + "Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.", + "Judge": "Julgador", + "KeepAlive": "KeepAlive", + "Key Name": "Nome da Chave", + "Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?", + "Kilo Code - Manual Configuration": "Kilo Code - Configuração Manual", + "Kilo Code not detected locally": "Kilo Code não detectado localmente", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE não detectado. Cole seu token de atualização manualmente.", + "Kompress-v2 HF model for prose/agentic traces (~+1GB)": "Modelo Kompress-v2 HF para texto/rastros agênticos (~+1GB)", + "Label": "Rótulo", + "Language": "Idioma", + "Last Page": "Última Página", + "Latency": "Latência", + "Latency:": "Latência:", + "Lazy senior dev": "Dev sênior preguiçoso", + "Leave blank to inherit Main Model. Each override keeps its own context window.": "Deixe em branco para herdar o Modelo Principal. Cada substituição mantém sua própria janela de contexto.", + "Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente", + "Leave empty for public PKCE app": "Deixe vazio para app PKCE público", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "Legal": "Legal", + "Light": "Claro", + "Live server console output": "Saída do console do servidor ao vivo", + "Load": "Carregar", + "Loading": "Carregando", + "Loading logs...": "Carregando logs...", + "Loading pricing data...": "Carregando dados de preço...", + "Loading registry...": "Carregando registro...", + "Loading reset credits...": "Carregando créditos de redefinição...", + "Loading...": "Carregando...", + "Local": "Local", + "Local Mode": "Modo local", + "Local Mode - All data stored on your machine": "Modo Local - Todos os dados armazenados em sua máquina", + "Local Plugins": "Plugins Locais", + "Login": "Conectar", + "Login Button Label": "Texto do Botão de Login", + "Login URL": "URL de Login", + "Login to your account": "Conecte-se à sua conta", + "Login with your GitHub account (manual callback).": "Faça login com sua conta GitHub (callback manual).", + "Login with your Google account (manual callback).": "Faça login com sua conta Google (callback manual).", + "Logout": "Sair", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs são carregados do banco de dados de histórico de requisições.", + "MCP": "MCP", + "MIT License": "Licença MIT", + "MITM": "MITM", + "MITM Server": "Servidor MITM", + "MITM Tools": "Ferramentas MITM", + "Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...", + "Main Model": "Modelo Principal", + "Manage": "Gerenciar", + "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Manage your preferences": "Gerenciar suas preferências", + "Manual / current endpoint": "Endpoint manual / atual", + "Manual Callback Required": "Callback Manual Necessário", + "Manual Config": "Configuração Manual", + "Manual configuration is still available if 9router is deployed on a remote server.": "A configuração manual ainda está disponível se o 9Router estiver implantado em um servidor remoto.", + "Mask (URL)": "Máscara (URL)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Max Records": "Número máximo de registros", "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalhes de solicitação a manter (registros antigos são excluídos automaticamente)", - "Batch Size": "Tamanho do lote", - "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", - "Flush Interval (ms)": "Intervalo de liberação (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", - "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (solicitação/resposta) antes do truncamento", - "All data stored on your machine": "Todos os dados armazenados em sua máquina", - "MITM Server": "Servidor MITM", - "Running": "Executando", - "Stopped": "Parado", - "Cert": "Certificado", - "Server": "Servidor", - "Purpose:": "Propósito:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", - "How it works:": "Como funciona:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", + "Media Providers": "Provedores de Mídia", + "Menu": "Menu", + "Message AI": "IA de Mensagens", + "Messages": "Mensagens", + "Minimum prompt size (chars)": "Tamanho mínimo do prompt (caracteres)", + "Model": "Modelo", + "Model ID": "ID do Modelo", + "Model ID (for Check)": "ID do Modelo (para Verificação)", + "Model ID (from OpenRouter)": "ID do Modelo (do OpenRouter)", + "Model ID (optional)": "ID do Modelo (opcional)", + "Model Status": "Status do Modelo", + "Model combos with fallback": "Combinações de modelos com fallback", + "Model is reachable": "Modelo está acessível", + "Model list is filtered from connected providers.": "Lista de modelos é filtrada dos provedores conectados.", + "Model mappings will be available soon.": "Mapeamentos de modelo estarão disponíveis em breve.", + "Model:": "Modelo:", + "Models": "Modelos", + "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", + "More on GitHub": "Mais no GitHub", + "Move down": "Mover para baixo", + "Move up": "Mover para cima", + "My Profile": "Meu Perfil", + "NPM": "NPM", + "Name": "Nome", + "Navigate to home": "Navegar para início", + "Network": "Rede", + "New": "Novo", + "New Password": "Nova senha", + "New password": "Nova senha", + "Next": "Próximo", + "Next accounts page": "Próxima página de contas", + "No": "Não", + "No API keys yet": "Nenhuma chave de API ainda", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", - "sk_9router (default)": "sk_9router (padrão)", + "No MCPs added": "Nenhum MCP adicionado", + "No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda", + "No Providers Connected": "Nenhum Provedor Conectado", + "No Proxy": "Sem proxy", + "No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.", + "No active proxy pools available.": "Nenhum pool de proxy ativo disponível.", + "No authentication required": "Nenhuma autenticação necessária", + "No combos yet": "Nenhum combo ainda", + "No combos yet.": "Nenhum combo ainda.", + "No connections": "Nenhuma conexão", + "No connections yet": "Nenhuma conexão ainda", + "No console logs yet.": "Nenhum log de console ainda.", + "No conversations yet.": "Nenhuma conversa ainda.", + "No data for this period": "Nenhum dado para este período", + "No install log yet.": "Nenhum log de instalação ainda.", + "No key configured": "Nenhuma chave configurada", + "No language selected": "Nenhum idioma selecionado", + "No languages found.": "Nenhum idioma encontrado.", + "No logs recorded yet.": "Nenhum log registrado ainda.", + "No models": "Nenhum modelo", + "No models added yet": "Nenhum modelo adicionado ainda", + "No models found": "Nenhum modelo encontrado", + "No models selected": "Nenhum modelo selecionado", + "No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)", + "No pricing data available": "Nenhum dado de preço disponível", + "No providers connected": "Nenhum provedor conectado", + "No providers match your search": "Nenhum provedor corresponde à sua pesquisa", + "No providers yet.": "Nenhum provedor ainda.", + "No providers.": "Nenhum provedor.", + "No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda", + "No quota data available": "Nenhum dado de cota disponível", + "No request details found": "Nenhum detalhe de requisição encontrado", + "No requests yet.": "Nenhuma requisição ainda.", + "No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.", + "No servers match filter": "Nenhum servidor corresponde ao filtro", + "No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.", + "No usage yet.": "Nenhum uso ainda.", + "None": "Nenhum", + "None (unbind all)": "Nenhum (desvincular todos)", + "Not configured": "Não configurado", + "Not installed": "Não instalado", + "Notice": "Aviso", + "Notifications": "Notificações", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", + "OAuth": "OAuth", + "OAuth App": "App OAuth", + "OAuth Providers": "Provedores OAuth", + "OIDC": "OIDC", + "OIDC Dashboard Login": "Login no Painel via OIDC", + "OK": "OK", + "Observability": "Observabilidade", + "Office Proxy": "Proxy de Escritório", + "Offline": "Offline", + "Ollama Host URL": "URL do Host Ollama", + "One key per line. Format:": "Uma chave por linha. Formato:", + "One-to-one (rotate)": "Um-para-um (rotacionar)", + "Online": "Online", + "Only from connected providers": "Apenas de provedores conectados", + "Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .", + "Open": "Abrir", + "Open Claw - Manual Configuration": "Open Claw - Configuração Manual", + "Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente", + "Open Dashboard": "Abrir Painel", + "Open DevTools (F12) → Application/Storage → Cookies": "Abra DevTools (F12) → Application/Storage → Cookies", + "Open Headroom Dashboard": "Abrir Painel Headroom", + "Open Logs": "Abrir Logs", + "Open menu": "Abrir menu", + "Open source": "Código aberto", + "Open source and free to start.": "Código aberto e gratuito para começar.", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenCode - Manual Configuration": "OpenCode - Configuração Manual", + "OpenCode CLI not detected locally": "OpenCode CLI não detectado localmente", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter suporta qualquer modelo. Adicione modelos e crie aliases para acesso rápido.", + "Optional": "Opcional", + "Or paste callback URL manually": "Ou cole a URL de callback manualmente", + "Organization": "Organização", + "Organization Domain": "Domínio da Organização", + "Organization ID": "ID da Organização", + "Organization Token": "Token da Organização", + "Organization Tokens": "Tokens da Organização", + "Original": "Original", + "Original (est.)": "Original (est.)", + "Original tokens": "Tokens originais", + "Other": "Outro", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Nosso mecanismo analisa o prompt, verifica a integridade do provedor e roteia para menor latência ou custo.", + "Out": "Saída", + "Outbound Proxy": "Proxy de saída", + "Output": "Saída", + "Output Format": "Formato de Saída", + "Output Tokens": "Tokens de Saída", + "Output Tokens:": "Tokens de Saída:", + "Output:": "Saída:", + "PATH": "PATH", + "PXPIPE": "PXPIPE", + "PXPIPE Dashboard": "Painel PXPIPE", + "PXPIPE Logs": "Logs PXPIPE", + "PXPIPE install failed": "Falha na instalação do PXPIPE", + "PXPIPE is not installed.": "PXPIPE não está instalado.", + "PXPIPE restart failed": "Falha ao reiniciar PXPIPE", + "PXPIPE start failed": "Falha ao iniciar PXPIPE", + "PXPIPE stop failed": "Falha ao parar PXPIPE", + "Paid": "Pago", + "Partial preview": "Visualização parcial", + "Password": "Senha", + "Password updated successfully": "Senha atualizada com sucesso", + "Passwords do not match": "As senhas não correspondem", + "Paste": "Colar", + "Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)", + "Paste it below": "Cole abaixo", + "Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.", + "Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.", + "Paste this to your AI:": "Cole isto em sua IA:", + "Paste your Kiro API key...": "Cole sua chave de API Kiro...", + "Paused": "Pausado", + "Permissions": "Permissões", + "Personal Access Token": "Token de Acesso Pessoal", + "Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel", + "Please copy the URL from the address bar and paste it in the application.": "Copie a URL da barra de endereço e cole no aplicativo.", + "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", + "Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.", + "Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128", + "Port 443 Already In Use": "Porta 443 já está em uso", + "Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:", + "Powerful Features": "Recursos Poderosos", + "Prefix": "Prefixo", + "Preset": "Predefinição", + "Previous": "Anterior", + "Previous accounts page": "Página anterior de contas", + "Price": "Preço", + "Pricing Configuration": "Configuração de Preços", + "Pricing Format:": "Formato de Preço:", + "Pricing Rates Format": "Formato das Taxas de Preço", + "Pricing Settings": "Configurações de Preço", + "Priority": "Prioridade", + "Privacy Policy": "Política de Privacidade", + "Probing server for tools...": "Verificando servidor por ferramentas...", + "Processing...": "Processando...", + "Product": "Produto", + "Production Key": "Chave de Produção", + "Profile": "Perfil", + "ProgramArguments": "Argumentos do Programa", + "Project Name": "Nome do Projeto", + "Prompt": "Prompt", + "Provider": "Provedor", + "Provider not found": "Provedor não encontrado", + "Provider:": "Provedor:", + "Providers": "Provedores", + "Proxy": "Proxy", + "Proxy Pool": "Pool de Proxy", + "Proxy Pools": "Pools de Proxy", + "Proxy URL": "URL do Proxy", + "Proxy disabled": "Proxy desativado", + "Proxy enabled": "Proxy ativado", + "Proxy settings applied": "Configurações de proxy aplicadas", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Purpose:": "Propósito:", + "Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.", + "Quota Tracker": "Rastreador de cota", + "Read Documentation": "Ler Documentação", + "Read this skill and use it:": "Leia esta skill e use-a:", + "Ready": "Pronto", + "Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?", + "Reasoning": "Raciocínio", + "Reasoning:": "Raciocínio:", + "Recent Requests": "Requisições Recentes", + "Recent chats": "Chats recentes", + "Recheck": "Verificar novamente", + "Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs", + "Redirect URI": "URI de Redirecionamento", + "Redo": "Refazer", + "Reduction": "Redução", + "Ref Image (URL)": "URL da Imagem de Referência", + "Refresh": "Atualizar", + "Refresh all": "Atualizar tudo", + "Refresh quota": "Atualizar cota", + "Region": "Região", + "Reload Page": "Recarregar página", + "Reload VS Code after applying for changes to take effect.": "Recarregue o VS Code após aplicar para que as alterações entrem em vigor.", + "Remaining": "Restante", + "Remote": "Remoto", + "Remove": "Remover", + "Remove [code] and its packages?": "Remover [code] e seus pacotes?", + "Remove [ml] and its packages?": "Remover [ml] e seus pacotes?", + "Remove attachment": "Remover anexo", + "Remove custom model": "Remover modelo personalizado", + "Remove failed": "Falha ao remover", + "Remove model": "Remover modelo", + "Repair": "Reparar", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "Substitui WebSearch/WebFetch nativos. Remove automaticamente duplicatas da lista de ferramentas.", + "Request": "Requisição", + "Request Details": "Detalhes da Requisição", + "Request Logs": "Logs de Requisições", + "Requests": "Requisições", + "Requests smaller than this bypass PXPIPE and are sent as-is.": "Requisições menores que isso ignoram PXPIPE e são enviadas como estão.", + "Requests without a valid key will be rejected": "Requisições sem uma chave válida serão rejeitadas", + "Require API key": "Exigir chave de API", + "Require login": "Exigir login", + "Required": "Obrigatório", + "Required for SSL certificate and DNS configuration": "Necessário para certificado SSL e configuração de DNS", + "Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor", + "Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS", + "Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.", + "Reset": "Redefinir", + "Reset Codex limit?": "Redefinir limite do Codex?", + "Reset Password to Default": "Redefinir Senha para Padrão", + "Reset judge to Auto": "Redefinir julgador para Automático", + "Reset to Defaults": "Redefinir para Padrões", + "Resources": "Recursos", + "Response": "Resposta", + "Response Format": "Formato da Resposta", + "Restart": "Reiniciar", + "Restart failed": "Falha ao reiniciar", + "Restarting proxy…": "Reiniciando proxy…", + "Restore model": "Restaurar modelo", + "Retry": "Tentar novamente", + "Risk Notice": "Aviso de Risco", + "Rotation Strategy": "Estratégia de Rotação", + "Round Robin": "Round Robin", + "Route Requests": "Rotear Requisições", + "Routing Strategy": "Estratégia de roteamento", + "Rows:": "Linhas:", + "Run": "Executar", + "Run npx command to start the server instantly": "Execute o comando npx para iniciar o servidor instantaneamente", + "RunAtLoad": "Executar ao Carregar", + "Running": "Executando", + "Running on your machine": "Executando em sua máquina", + "SSE URL": "URL SSE", + "START HERE": "COMECE AQUI", + "Save": "Salvar", + "Save Mappings": "Salvar Mapeamentos", + "Save auth mode": "Salvar modo de autenticação", + "Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador", + "Save this key now!": "Salve esta chave agora!", + "Saved": "Salvo", + "Scopes": "Escopos", + "Scroll down to": "Role para baixo até", + "Search": "Pesquisar", + "Search by name or description...": "Pesquisar por nome ou descrição...", + "Search language...": "Pesquisar idioma...", + "Search models...": "Pesquisar modelos...", + "Search providers...": "Pesquisar provedores...", + "Search...": "Pesquisar...", + "Security": "Segurança", + "Security risk: no password set.": "Risco de segurança: nenhuma senha definida.", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.", + "Select": "Selecionar", + "Select All": "Selecionar Todos", + "Select Cowork Model": "Selecionar Modelo Cowork", + "Select Endpoint": "Selecionar Endpoint", + "Select Judge Model": "Selecionar Modelo Julgador", + "Select Language": "Selecionar Idioma", + "Select Model": "Selecionar Modelo", + "Select Model for Cline": "Selecionar Modelo para Cline", + "Select Model for Codex": "Selecionar Modelo para Codex", + "Select Model for DeepSeek TUI": "Selecionar Modelo para DeepSeek TUI", + "Select Model for Factory Droid": "Selecionar Modelo para Factory Droid", + "Select Model for Hermes Agent": "Selecionar Modelo para Hermes Agent", + "Select Model for Kilo Code": "Selecionar Modelo para Kilo Code", + "Select Model for Open Claw": "Selecionar Modelo para Open Claw", + "Select Model for jcode": "Selecionar Modelo para jcode", + "Select Subagent Model for Codex": "Selecionar Modelo de Subagente para Codex", + "Select Subagent Model for OpenCode": "Selecionar Modelo de Subagente para OpenCode", + "Select a provider": "Selecionar um provedor", + "Select language": "Selecionar idioma", + "Select your": "Selecione seu(sua)", + "Selected provider": "Provedor selecionado", + "Send": "Enviar", + "Server": "Servidor", + "Server Disconnected": "Servidor desconectado", + "Server off": "Servidor desligado", "Server started": "Servidor iniciado", - "Failed to start server": "Falha ao iniciar o servidor", "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", - "Failed to stop server": "Falha ao parar o servidor", - "Sudo password is required": "Senha sudo é necessária", - "Stop Server": "Parar servidor", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", + "Set Password": "Definir senha", + "Set a new password before accessing the dashboard remotely.": "Defina uma nova senha antes de acessar o painel remotamente.", + "Set password": "Definir senha", + "Settings": "Configurações", + "Setup": "Configurar", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "Configuração + índice de todas as capacidades. Comece aqui — cobre URL base, autenticação, descoberta de modelos e links para todas as skills.", + "Setup Headroom": "Configurar Headroom", + "Setup PXPIPE": "Configurar PXPIPE", + "Show this quota row": "Mostrar esta linha de cota", + "Shutdown": "Desligar", + "Sign in with OIDC": "Entrar com OIDC", + "Single": "Único", + "Sort": "Classificar", + "Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante", + "Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota", + "Speech-to-Text": "Fala-para-Texto", + "StandardErrorPath": "Caminho do Erro Padrão", + "StandardOutPath": "Caminho da Saída Padrão", + "Start": "Iniciar", + "Start DNS": "Iniciar DNS", + "Start Date": "Data de Início", + "Start Free": "Começar Gratuito", + "Start Headroom": "Iniciar Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Inicie o Headroom separadamente na URL configurada e verifique novamente.", + "Start MITM": "Iniciar MITM", "Start Server": "Iniciar servidor", - "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", - "Sudo Password Required": "Senha Sudo necessária", - "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "Start Tunnel": "Iniciar Túnel", + "Start a conversation": "Iniciar uma conversa", + "Starting…": "Iniciando…", + "Status": "Status", + "Status:": "Status:", + "Step 1: Open this URL in your browser": "Passo 1: Abra esta URL no seu navegador", + "Step 2: Paste the callback URL here": "Passo 2: Cole a URL de callback aqui", + "Sticky Limit": "Limite pegajoso", + "Sticky:": "Fixo:", + "Stop": "Parar", + "Stop DNS": "Parar DNS", + "Stop Headroom": "Parar Headroom", + "Stop MITM": "Parar MITM", + "Stop Server": "Parar servidor", + "Stopped": "Parado", + "Stopping…": "Parando…", + "Strict Proxy": "Proxy Estrito", + "Style": "Estilo", + "Subagent Model": "Modelo de Subagente", + "Subagent model overrides": "Substituições de modelo de subagente", + "Submit": "Enviar", + "Success": "Sucesso", "Sudo Password": "Senha sudo", - "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco.", + "Sudo Password Required": "Senha Sudo necessária", + "Sudo password is required": "Senha sudo é necessária", + "Support": "Suporte", + "Switch language": "Trocar idioma", + "System": "Sistema", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.", + "Tailscale installed": "Tailscale instalado", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "Temperatura", + "Terms of Service": "Termos de Serviço", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)", + "Test Example": "Exemplo de Teste", + "Test Results": "Resultados do Teste", + "Test all API Key connections": "Testar todas as conexões de Chave API", + "Test all Free connections": "Testar todas as conexões Gratuitas", + "Test all Free provider connections": "Testar todas as conexões de provedores Gratuitos", + "Test all OAuth connections": "Testar todas as conexões OAuth", + "Test connection": "Testar conexão", + "Test proxy": "Testar proxy", + "Test proxy URL": "Testar URL do proxy", + "Text-to-Speech": "Texto-para-Fala", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", + "The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.", + "Theme": "Tema", + "Thinking Process": "Processo de Raciocínio", + "This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.", + "Time": "Hora", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "Toggle auto-ping": "Alternar ping automático", + "Token Saver": "Economizador de Tokens", + "Token Saver settings": "Configurações do Economizador de Tokens", + "Token Types:": "Tipos de Token:", + "Tokens": "Tokens", + "Tools": "Ferramentas", + "Total": "Total", + "Total Input Tokens": "Total de Tokens de Entrada", + "Total Models": "Total de Modelos", + "Total Requests": "Total de Requisições", + "Total:": "Total:", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + "Translator Debug": "Depuração do Tradutor", + "Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.", + "Trust Cert": "Confiar Certificado", + "Try Again": "Tentar Novamente", + "Tunnel": "Túnel", + "Turn off Empty": "Desligar Vazio", + "Turn on Available": "Ligar Disponível", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Twitter": "Twitter", + "Type": "Tipo", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / texto / HTML via Firecrawl, Jina, Tavily, Exa.", + "Unavailable": "Indisponível", + "Under": "Abaixo", + "Undo": "Desfazer", + "Uninstall": "Desinstalar", + "Uninstalling…": "Desinstalando…", + "Update": "Atualizar", + "Update 9Router": "Atualizar 9Router", + "Update Password": "Atualizar senha", + "Update now": "Atualizar agora", + "Updated": "Atualizado", + "Upload": "Enviar", + "Uptime": "Tempo de atividade", + "Usage": "Uso", + "Usage Logs": "Logs de Uso", + "Usage:": "Uso:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", + "Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab", + "Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API", + "Valid": "Válido", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…", + "Vercel API Token": "Token de API Vercel", + "Vercel Relay": "Vercel Relay", + "Version": "Versão", + "View": "Visualizar", + "View Codex reset credit expiry": "Ver expiração de crédito do Codex", + "View Full Details": "Ver Detalhes Completos", + "View on GitHub": "Ver no GitHub", + "Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:", + "Voice": "Voz", + "Voice ID": "ID de Voz", + "Voyage AI": "Voyage AI", + "Waiting for authorization...": "Aguardando autorização...", + "Warning": "Aviso", + "Web Fetch": "Fetch Web", + "Web Search": "Busca Web", + "What is Cloudflare Relay?": "O que é Cloudflare Relay?", + "What is Deno Relay?": "O que é Deno Relay?", + "What is Vercel Relay?": "O que é Vercel Relay?", + "When": "Quando", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM", + "Worker Name": "Nome do Worker", + "Writes to": "Grava em", + "Yes": "Sim", + "You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.", + "Your Account Name": "Nome da Sua Conta", + "Your Code": "Seu Código", + "Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth", + "Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.", + "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "ex.: voz quente e suave, falando devagar com sotaque britânico", + "extras status failed": "falha no status dos extras", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 60-90% menos tokens de entrada", + "not installed": "não instalado", + "sk_9router (default)": "sk_9router (padrão)", + "tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", - "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco." } diff --git a/public/i18n/literals/pt-PT.json b/public/i18n/literals/pt-PT.json index c17e932a..dfa1270f 100644 --- a/public/i18n/literals/pt-PT.json +++ b/public/i18n/literals/pt-PT.json @@ -12,6 +12,7 @@ "Logout": "Terminar sessão", "Login": "Iniciar sessão", "Providers": "Fornecedores", + "Style": "Estilo", "Usage": "Estatísticas", "API Key": "Chave API", "Connected": "Ligado", @@ -176,6 +177,7 @@ "How it works:": "Como funciona:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Pedido do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM interceta → 9Router → resposta para Antigravity/Copilot", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "ex.: voz quente e suave, a falar devagar com sotaque britânico", "sk_9router (default)": "sk_9router (predefinição)", "Server started": "Servidor iniciado", "Failed to start server": "Falha ao iniciar o servidor", diff --git a/public/i18n/literals/ro.json b/public/i18n/literals/ro.json index 03384415..d9857d51 100644 --- a/public/i18n/literals/ro.json +++ b/public/i18n/literals/ro.json @@ -12,6 +12,7 @@ "Logout": "Ieșire", "Login": "Conectare", "Providers": "Furnizori", + "Style": "Stil", "Usage": "Statistici de utilizare", "API Key": "Cheie API", "Connected": "Conectat", @@ -176,6 +177,7 @@ "How it works:": "Cum funcționează:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitare Antigravity/Copilot IDE → Redirecționare DNS la localhost:443 → Proxy MITM interceptează → 9Router → răspuns la Antigravity/Copilot", "No API keys — create one in Keys page": "Nicio cheie API — creați una în pagina Chei", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "ex.: voce caldă și blândă, vorbind încet cu accent britanic", "sk_9router (default)": "sk_9router (implicit)", "Server started": "Server pornit", "Failed to start server": "Nu s-a putut porni serverul", diff --git a/public/i18n/literals/ru.json b/public/i18n/literals/ru.json index 92b9f0e7..c34535ee 100644 --- a/public/i18n/literals/ru.json +++ b/public/i18n/literals/ru.json @@ -12,6 +12,7 @@ "Logout": "Выход", "Login": "Вход", "Providers": "Провайдеры", + "Style": "Стиль", "Usage": "Статистика", "API Key": "Ключ API", "Connected": "Подключено", @@ -176,6 +177,7 @@ "How it works:": "Как это работает:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запрос Antigravity/Copilot IDE → Перенаправление DNS на localhost:443 → Прокси MITM перехватывает → 9Router → ответ для Antigravity/Copilot", "No API keys — create one in Keys page": "Нет ключей API — создайте один на странице ключей", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "напр.: тёплый мягкий голос, медленно говорящий с британским акцентом", "sk_9router (default)": "sk_9router (по умолчанию)", "Server started": "Сервер запущен", "Failed to start server": "Ошибка при запуске сервера", diff --git a/public/i18n/literals/sv.json b/public/i18n/literals/sv.json index 0e4c3b46..ef45f379 100644 --- a/public/i18n/literals/sv.json +++ b/public/i18n/literals/sv.json @@ -12,6 +12,7 @@ "Logout": "Logga ut", "Login": "Logga in", "Providers": "Leverantörer", + "Style": "Stil", "Usage": "Användarstatistik", "API Key": "API-nyckel", "Connected": "Ansluten", @@ -176,6 +177,7 @@ "How it works:": "Hur det fungerar:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-begäran → DNS-omdirigering till localhost:443 → MITM-proxy avlyssnar → 9Router → svar till Antigravity/Copilot", "No API keys — create one in Keys page": "Inga API-nycklar — skapa en på nyckelsidan", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "t.ex.: en varm, mjuk röst som talar långsamt med brittisk accent", "sk_9router (default)": "sk_9router (standard)", "Server started": "Servern startad", "Failed to start server": "Misslyckades att starta servern", diff --git a/public/i18n/literals/th.json b/public/i18n/literals/th.json index 6169829f..bc4d9376 100644 --- a/public/i18n/literals/th.json +++ b/public/i18n/literals/th.json @@ -1,195 +1,1393 @@ { - "Cancel": "ยกเลิก", - "Delete": "ลบ", - "Edit": "แก้ไข", - "Save": "บันทึก", - "Close": "ปิด", + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "(฿/1M tokens) ตัวอย่าง: อัตราขาเข้า 2.50 หมายถึง $2.50 ต่อ 1,000,000 input tokens", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "(฿/1M tokens) ตัวอย่าง: อัตราขาเข้า 2.50 หมายถึง $2.50 ต่อ 1,000,000 input tokens", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(ผ่านการทดสอบ推理)", + "+ Browse": "+ เรียกดู", + "+ Combo": "+ Combo", + "+ Custom": "+ กำหนดเอง", + "+ Save current as...": "+ บันทึกปัจจุบันเป็น...", + "-compatible models manually or import them from the /models endpoint.": "- เพิ่มโมเดลแบบ compatible ด้วยตนเอง หรือนำเข้าจาก /models endpoint", + ". Click \"Apply\" to auto-configure.": ". คลิก \"Apply\" เพื่อกำหนดค่าอัตโนมัติ", + "1. CLI & SDKs": "1. CLI & SDKs", + "1. Client Request (Input)": "1. คำขอจากลูกค้า (ขาเข้า)", + "1. Generates SSL cert & adds to system keychain": "1. สร้าง SSL cert แล้วเพิ่มเข้า system keychain", + "2. 9Router Hub": "2. 9Router Hub", + "2. Provider Request (Translated)": "2. คำขอจากผู้ให้บริการ (แปลแล้ว)", + "2. Redirects": "2. Redirects", + "24h": "24 ชม.", + "3. AI Providers": "3. ผู้ให้บริการ AI", + "3. Maps Antigravity models to any provider via 9Router": "3. Map โมเดล Antigravity ไปยังผู้ให้บริการใดก็ได้ผ่าน 9Router", + "3. Provider Response (Raw)": "3. การตอบกลับจากผู้ให้บริการ (ดิบ)", + "30D": "30 วัน", + "4. Client Response (Final)": "4. การตอบกลับลูกค้า (สุดท้าย)", + "60D": "60 วัน", + "7D": "7 วัน", + "9Router (Entry)": "9Router (ทางเข้า)", + "9Router Base URL": "9Router Base URL", + ": Account | Workers Scripts | Edit": ": บัญชี | Workers Scripts | Edit", + ": Include | Account |": ": Include | บัญชี |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "AI endpoint proxy พร้อม web dashboard — JavaScript port ของ CLIProxyAPI ใช้งานร่วมกับ Claude Code, OpenAI Codex, Cline, RooCode และเครื่องมือ CLI อื่นๆ ได้อย่างราบรื่น", + "API Endpoint": "API Endpoint", + "API Key": "API Key", + "API Key (for Check)": "API Key (สำหรับตรวจสอบ)", + "API Key Compatible Providers": "ผู้ให้บริการที่ compatible กับ API Key", + "API Key Created": "สร้าง API Key แล้ว", + "API Key Name": "ชื่อ API Key", + "API Key Providers": "ผู้ให้บริการ API Key", + "API Keys": "API Keys", + "API Reference": "เอกสาร API", + "API Token": "API Token", + "API Tokens": "API Tokens", + "API Type": "ประเภท API", + "API Version": "เวอร์ชัน API", + "API endpoint configuration": "การกำหนดค่า API endpoint", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "AWS Region", + "AWS region for the key (default: us-east-1)": "AWS region สำหรับ key (ค่าเริ่มต้น: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "AWS region สำหรับ Identity Center ของคุณ (ค่าเริ่มต้น: us-east-1)", + "About": "เกี่ยวกับ", + "Access Anywhere": "เข้าถึงได้ทุกที่", + "Access Token": "Access Token", + "Access token will be auto-filled...": "Access token จะถูกเติมอัตโนมัติ...", + "Access your terminal, desktop & files from anywhere": "เข้าถึง terminal, desktop และไฟล์ของคุณจากทุกที่", + "Account": "บัญชี", + "Account ID": "Account ID", + "Account Resources": "ทรัพยากรบัญชี", + "Accounts per page": "จำนวนบัญชีต่อหน้า", + "Action": "การดำเนินการ", + "Activate": "เปิดใช้งาน", + "Active": "ใช้งานอยู่", + "Active All": "เปิดใช้งานทั้งหมด", + "Active:": "ใช้งานอยู่:", "Add": "เพิ่ม", - "Remove": "นำออก", - "Settings": "การตั้งค่า", - "Profile": "โปรไฟล์", - "Dashboard": "แดชบอร์ด", - "Logout": "ออกจากระบบ", - "Login": "เข้าสู่ระบบ", - "Providers": "ผู้ให้บริการ", - "Usage": "สถิติการใช้งาน", - "API Key": "คีย์ API", - "Connected": "เชื่อมต่อแล้ว", - "Disconnected": "ตัดการเชื่อมต่อ", - "Active": "ใช้งาน", - "Inactive": "ไม่ใช้งาน", - "Success": "สำเร็จ", - "Failed": "ล้มเหลว", - "Error": "ข้อผิดพลาด", - "Warning": "คำเตือน", - "Info": "ข้อมูล", - "Loading": "กำลังโหลด", - "Search": "ค้นหา", - "Filter": "ตัวกรอง", - "Sort": "เรียงลำดับ", - "Export": "ส่งออก", - "Import": "นำเข้า", - "Refresh": "รีเฟรช", - "Back": "ย้อนกลับ", - "Next": "ถัดไป", - "Previous": "ก่อนหน้า", - "Submit": "ส่ง", - "Confirm": "ยืนยัน", - "Yes": "ใช่", - "No": "ไม่", - "OK": "ตกลง", + "Add API Key": "เพิ่ม API Key", + "Add Anthropic Compatible": "เพิ่ม Anthropic Compatible", + "Add Connection": "เพิ่มการเชื่อมต่อ", + "Add Custom Embedding": "เพิ่ม Custom Embedding", + "Add Custom MCP": "เพิ่ม Custom MCP", + "Add Custom Model": "เพิ่ม Custom Model", + "Add Model": "เพิ่มโมเดล", + "Add Model Config": "เพิ่มการกำหนดค่าโมเดล", + "Add Model for GitHub Copilot": "เพิ่มโมเดลสำหรับ GitHub Copilot", + "Add Model for OpenCode": "เพิ่มโมเดลสำหรับ OpenCode", + "Add Model to Combo": "เพิ่มโมเดลเข้า Combo", + "Add New Provider": "เพิ่มผู้ให้บริการใหม่", + "Add OpenAI Compatible": "เพิ่ม OpenAI Compatible", + "Add Provider": "เพิ่มผู้ให้บริการ", + "Add Proxy Pool": "เพิ่ม Proxy Pool", + "Add Shorthands": "เพิ่ม Shorthands", + "Add a connection to enable importing models.": "เพิ่มการเชื่อมต่อเพื่อเปิดใช้งานการนำเข้าโมเดล", + "Add connection using browser cookie": "เพิ่มการเชื่อมต่อโดยใช้ browser cookie", + "Add model": "เพิ่มโมเดล", + "Add server": "เพิ่มเซิร์ฟเวอร์", + "Add the following configuration to your models array:": "เพิ่มการกำหนดค่าต่อไปนี้ใน models array ของคุณ:", + "Add your first connection to get started": "เพิ่มการเชื่อมต่อแรกของคุณเพื่อเริ่มต้น", + "Administrator required": "ต้องใช้สิทธิ์ผู้ดูแลระบบ", + "Administrator required — restart 9Router as Administrator to use MITM": "ต้องใช้สิทธิ์ผู้ดูแลระบบ — เริ่มต้น 9Router ใหม่ในฐานะผู้ดูแลระบบเพื่อใช้ MITM", + "After authorization, copy the full URL from your browser address bar.": "หลังการอนุมัติ คัดลอก URL เต็มจาก address bar ของเบราว์เซอร์", + "After authorization, copy the full URL from your browser.": "หลังการอนุมัติ คัดลอก URL เต็มจากเบราว์เซอร์ของคุณ", + "After installation, run": "หลังการติดตั้ง รัน", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "หลังเข้าสู่ระบบ คุณจะต้องคัดลอก callback URL จากเบราว์เซอร์แล้ววางกลับมาที่นี่", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "Alibaba Qwen Code CLI — รองรับผู้ให้บริการ OpenAI, Anthropic & Gemini ผ่าน 9Router", + "All": "ทั้งหมด", + "All AI Providers": "ผู้ให้บริการ AI ทั้งหมด", + "All Providers": "ผู้ให้บริการทั้งหมด", + "All models are responding normally.": "โมเดลทั้งหมดตอบสนองปกติ", + "All providers": "ผู้ให้บริการทั้งหมด", + "All rates are in": "อัตราทั้งหมดเป็น", + "All selected currently unbound": "ที่เลือกทั้งหมดยังไม่ได้เชื่อมต่อ", + "Allow dashboard access via tunnel": "อนุญาตให้เข้าถึง dashboard ผ่าน tunnel", + "Allow either password or OIDC.": "อนุญาตทั้งรหัสผ่านหรือ OIDC", + "An error occurred": "เกิดข้อผิดพลาด", + "An error occurred. Please try again.": "เกิดข้อผิดพลาด กรุณาลองใหม่", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "Anthropic Compatible (Production)", + "Anthropic Compatible Details": "รายละเอียด Anthropic Compatible", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "คำขอ Antigravity/Copilot IDE → DNS redirect ไปยัง localhost:443 → MITM proxy ดักจับ → 9Router → ส่งกลับไปยัง Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "โมเดลใดก็ได้ที่มีใน 9Router สามารถใช้ได้ — ไม่ใช่แค่ Qwen เลือกจาก Qwen, Claude, Gemini, GPT และอื่นๆ", + "App Name": "ชื่อแอป", "Apply": "ใช้", - "Reset": "รีเซ็ต", - "Clear": "ล้าง", - "Select": "เลือก", - "Upload": "อัพโหลด", - "Download": "ดาวน์โหลด", - "Copy": "คัดลอก", - "Paste": "วาง", - "Cut": "ตัด", - "Undo": "ยกเลิก", - "Redo": "ทำซ้ำ", - "Name": "ชื่อ", - "Description": "คำอธิบาย", - "Status": "สถานะ", - "Type": "ประเภท", - "Date": "วันที่", - "Time": "เวลา", - "Created": "สร้างแล้ว", - "Updated": "อัพเดตแล้ว", - "Actions": "การกระทำ", - "Details": "รายละเอียด", - "View": "ดู", - "New": "ใหม่", - "Total": "ทั้งหมด", - "Count": "จำนวน", - "Price": "ราคา", - "Cost": "ต้นทุน", - "Free": "ฟรี", - "Paid": "จ่ายเงิน", - "Enable": "เปิดใช้งาน", - "Disable": "ปิดใช้งาน", - "Enabled": "เปิดใช้งานแล้ว", - "Disabled": "ปิดใช้งานแล้ว", - "Online": "ออนไลน์", - "Offline": "ออฟไลน์", + "Apply Proxy": "ใช้ Proxy", + "Applying...": "กำลังใช้...", + "Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิด proxy server?", + "Are you sure you want to disable the tunnel?": "คุณแน่ใจหรือว่าต้องการปิด tunnel?", + "Attempting to reconnect...": "กำลังพยายามเชื่อมต่อใหม่...", + "Audio File": "ไฟล์เสียง", + "Auth Mode": "โหมดยืนยันตัวตน", + "Authenticate": "ยืนยันตัวตน", + "Authentication Method": "วิธียืนยันตัวตน", + "Authentication Successful": "ยืนยันตัวตนสำเร็จ", + "Authentication Successful!": "ยืนยันตัวตนสำเร็จ!", + "Authless": "ไม่ต้องยืนยันตัวตน", + "Authorization Successful!": "อนุมัติสำเร็จ!", + "Authorize": "อนุมัติ", + "Auto (by priority)": "อัตโนมัติ (ตามลำดับความสำคัญ)", + "Auto Refresh (3s)": "รีเฟรชอัตโนมัติ (3 วินาที)", + "Auto-detect": "ตรวจจับอัตโนมัติ", + "Auto-detecting token...": "กำลังตรวจจับ token...", + "Auto-detecting tokens...": "กำลังตรวจจับ tokens...", + "Auto-ping": "Auto-ping", + "Auto-refresh": "รีเฟรชอัตโนมัติ", + "Auto:": "อัตโนมัติ:", + "Automatically switch between providers when limits are hit.": "สลับระหว่างผู้ให้บริการโดยอัตโนมัติเมื่อถึงขีดจำกัด", "Available": "พร้อมใช้งาน", - "Unavailable": "ไม่พร้อมใช้งาน", - "Required": "จำเป็น", - "Optional": "ไม่บังคับ", - "Default": "ค่าเริ่มต้น", - "Custom": "กำหนดเอง", - "Advanced": "ขั้นสูง", - "Basic": "พื้นฐาน", - "Help": "ช่วยเหลือ", - "Support": "สนับสนุน", - "Documentation": "เอกสาร", - "Version": "เวอร์ชัน", - "Language": "ภาษา", - "Theme": "ธีม", - "Light": "สว่าง", - "Dark": "มืด", - "Auto": "อัตโนมัติ", - "Endpoint": "จุดสิ้นสุด", - "Combos": "ชุดรวม", - "Quota Tracker": "ตัวติดตามโควต้า", - "MITM": "MITM", - "CLI Tools": "เครื่องมือ", - "Console Log": "บันทึกคอนโซล", - "System": "ระบบ", - "Debug": "ดีบัก", - "Shutdown": "ปิดระบบ", - "Close Proxy": "ปิด Proxy", - "Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิดเซิร์ฟเวอร์ proxy?", - "Server Disconnected": "เซิร์ฟเวอร์ตัดการเชื่อมต่อ", - "The proxy server has been stopped.": "เซิร์ฟเวอร์ proxy ถูกหยุดแล้ว", - "Reload Page": "โหลดหน้าใหม่", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "บริการกำลังทำงานในเทอร์มินัล คุณสามารถปิดหน้าเว็บนี้ได้ การปิดระบบจะหยุดบริการ", - "Manage your AI provider connections": "จัดการการเชื่อมต่อผู้ให้บริการ AI ของคุณ", - "Model combos with fallback": "ชุดรวมโมเดลที่มี fallback", - "Monitor your API usage, token consumption, and request logs": "ติดตามการใช้งาน API การใช้งาน token และบันทึกคำขอของคุณ", - "Intercept CLI tool traffic and route through 9Router": "สกัดปะท่อ CLI และเส้นทางผ่าน 9Router", - "Configure CLI tools": "กำหนดค่าเครื่องมือ CLI", - "API endpoint configuration": "การตั้งค่าจุดสิ้นสุด API", - "Manage your preferences": "จัดการการตั้งค่าของคุณ", - "Debug translation flow between formats": "ดีบักการไหลของการแปลระหว่างรูปแบบ", - "Live server console output": "ผลลัพธ์คอนโซลเซิร์ฟเวอร์สด", - "Create model combos with fallback support": "สร้างชุดรวมโมเดลที่มีการสนับสนุน fallback", - "Local Mode": "โหมดท้องถิ่น", - "Running on your machine": "ทำงานบนเครื่องของคุณ", - "Database Location": "ตำแหน่งของฐานข้อมูล", - "Download Backup": "ดาวน์โหลดการสำรองข้อมูล", - "Import Backup": "นำเข้าการสำรองข้อมูล", - "Database backup downloaded": "ดาวน์โหลดการสำรองข้อมูลฐานข้อมูลแล้ว", - "Database imported successfully": "นำเข้าฐานข้อมูลเสร็จสิ้น", - "Security": "ความปลอดภัย", - "Require login": "ต้องการการเข้าสู่ระบบ", - "When ON, dashboard requires password. When OFF, access without login.": "เมื่อเปิด แดชบอร์ดต้องการรหัสผ่าน เมื่อปิด เข้าถึงโดยไม่ต้องเข้าสู่ระบบ", - "Current Password": "รหัสผ่านปัจจุบัน", - "Enter current password": "ป้อนรหัสผ่านปัจจุบัน", - "New Password": "รหัสผ่านใหม่", - "Enter new password": "ป้อนรหัสผ่านใหม่", - "Confirm New Password": "ยืนยันรหัสผ่านใหม่", - "Confirm new password": "ยืนยันรหัสผ่านใหม่", - "Update Password": "อัพเดตรหัสผ่าน", - "Set Password": "ตั้งรหัสผ่าน", - "Password updated successfully": "อัพเดตรหัสผ่านเสร็จสิ้น", - "Passwords do not match": "รหัสผ่านไม่ตรงกัน", - "Routing Strategy": "กลยุทธ์การเส้นทาง", - "Round Robin": "โรบินรอบ", - "Cycle through accounts to distribute load": "วนรอบบัญชีเพื่อกระจายการโหลด", - "Sticky Limit": "ขีดจำกัดที่เหนียว", - "Calls per account before switching": "การโทรต่อบัญชีก่อนการสลับ", - "Network": "เครือข่าย", - "Outbound Proxy": "Proxy ขาออก", - "Enable proxy for OAuth + provider outbound requests.": "เปิดใช้งาน proxy สำหรับคำขอขาออก OAuth + ผู้ให้บริการ", - "Proxy URL": "URL Proxy", - "Leave empty to inherit existing env proxy (if any).": "ปล่อยว่างไว้เพื่อสืบทอด proxy env ที่มีอยู่ (หากมี)", - "No Proxy": "ไม่มี Proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "ชื่อโฮสต์/โดเมนคั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", - "Test proxy URL": "ทดสอบ URL Proxy", - "Proxy settings applied": "ใช้การตั้งค่า proxy แล้ว", - "Proxy enabled": "เปิดใช้งาน proxy", - "Proxy disabled": "ปิดใช้งาน proxy", - "Proxy test OK": "ทดสอบ proxy ตกลง", - "Proxy test failed": "ทดสอบ proxy ล้มเหลว", - "Please enter a Proxy URL to test": "กรุณาป้อน URL Proxy เพื่อทดสอบ", - "Observability": "ความสามารถในการสังเกต", - "Enable Observability": "เปิดใช้งานความสามารถในการสังเกต", - "Turn request detail recording on/off globally": "เปิด/ปิดการบันทึกรายละเอียดคำขอทั่วโลก", - "Max Records": "บันทึกสูงสุด", - "Maximum request detail records to keep (older records are auto-deleted)": "บันทึกรายละเอียดคำขอสูงสุดที่จะเก็บ (บันทึกเก่าจะลบโดยอัตโนมัติ)", - "Batch Size": "ขนาดแบตช์", - "Number of items to accumulate before writing to database (higher = better performance)": "จำนวนรายการที่จะรวบรวมก่อนเขียนลงฐานข้อมูล (สูงกว่า = ประสิทธิภาพดีกว่า)", - "Flush Interval (ms)": "ช่วงเวลาล้าง (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "เวลารอสูงสุดก่อนล้างบัฟเฟอร์ (ป้องกันการสูญหายข้อมูลในช่วงจราจรต่ำ)", - "Max JSON Size (KB)": "ขนาด JSON สูงสุด (KB)", - "Maximum size for each JSON field (request/response) before truncation": "ขนาดสูงสุดสำหรับแต่ละช่อง JSON (คำขอ/การตอบสนอง) ก่อนการตัดทอน", - "All data stored on your machine": "ข้อมูลทั้งหมดจัดเก็บไว้บนเครื่องของคุณ", - "MITM Server": "เซิร์ฟเวอร์ MITM", - "Running": "กำลังทำงาน", - "Stopped": "หยุดแล้ว", + "Available Models": "โมเดลที่พร้อมใช้งาน", + "Azure Endpoint": "Azure Endpoint", + "Azure OpenAI Configuration": "การกำหนดค่า Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "ย้อนกลับ", + "Back to CLI Tools": "กลับไป CLI Tools", + "Back to Providers": "กลับไปยังผู้ให้บริการ", + "Base URL": "Base URL", + "Batch Import": "นำเข้าแบบ Batch", + "Batch Import Proxies": "นำเข้า Proxies แบบ Batch", + "Batch Size": "ขนาด Batch", + "Beautiful web dashboard for managing providers and monitoring usage.": "web dashboard สวยงามสำหรับจัดการผู้ให้บริการและตรวจสอบการใช้งาน", + "Best quality, but costs the most": "คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "บังคับโมเดลให้เขียน code น้อยที่สุด: YAGNI, ใช้ stdlib ซ้ำ, ลบมากกว่าเพิ่ม", + "Binary File": "ไฟล์ไบนารี", + "Blog": "บล็อก", + "Both": "ทั้งคู่", + "Browse & edit files": "เรียกดูและแก้ไขไฟล์", + "Browse MCP Marketplace": "เรียกดู MCP Marketplace", + "Browse source, README, and examples.": "เรียกดู source, README และตัวอย่าง", + "Browser Control (Browser MCP)": "การควบคุมเบราว์เซอร์ (Browser MCP)", + "Bulk Add": "เพิ่มจำนวนมาก", + "CLI Support": "รองรับ CLI", + "CLI Tools": "เครื่องมือ CLI", + "CLI on the host →": "CLI บนโฮสต์ →", + "CLIProxyAPI Auth JSON": "CLIProxyAPI Auth JSON", + "Cache Creation": "สร้าง Cache", + "Cache Creation:": "สร้าง Cache:", + "Cached": "แคช", + "Cached Tokens": "Cached Tokens", + "Cached Tokens:": "Cached Tokens:", + "Cached input tokens (typically 50% of input rate)": "Input tokens ที่แคช (ปกติคิดอัตรา 50% ของ input rate)", + "Cached:": "แคช:", + "Calls per account before switching": "จำนวนเรียกก่อนสลับบัญชี", + "Calls per combo model before switching": "จำนวนเรียกก่อนสลับโมเดล combo", + "Cancel": "ยกเลิก", + "Capacity auto-switch": "สลับอัตโนมัติเมื่อเต็ม", "Cert": "ใบรับรอง", - "Server": "เซิร์ฟเวอร์", - "Purpose:": "วัตถุประสงค์:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ใช้ Antigravity IDE & GitHub Copilot → ที่มีผู้ให้บริการ/โมเดลใด ๆ จาก 9Router", - "How it works:": "วิธีการทำงาน:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "คำขอ Antigravity/Copilot IDE → เปลี่ยนเส้นทาง DNS เป็น localhost:443 → MITM proxy สกัดปะท่อ → 9Router → ตอบสนอง Antigravity/Copilot", - "No API keys — create one in Keys page": "ไม่มีคีย์ API — สร้างคีย์ในหน้า Keys", - "sk_9router (default)": "sk_9router (ค่าเริ่มต้น)", - "Server started": "เซิร์ฟเวอร์เริ่มต้นแล้ว", - "Failed to start server": "ไม่สามารถเริ่มเซิร์ฟเวอร์", - "Server stopped — all DNS cleared": "หยุดเซิร์ฟเวอร์ — ล้าง DNS ทั้งหมด", - "Failed to stop server": "ไม่สามารถหยุดเซิร์ฟเวอร์", - "Sudo password is required": "ต้องการรหัสผ่าน sudo", - "Stop Server": "หยุดเซิร์ฟเวอร์", - "Start Server": "เริ่มเซิร์ฟเวอร์", - "Enable DNS per tool below to activate interception": "เปิดใช้งาน DNS สำหรับแต่ละเครื่องมือด้านล่างเพื่อเปิดใช้งานการสกัดปะท่อ", - "Sudo Password Required": "ต้องการรหัสผ่าน Sudo", - "Enter your sudo password to start/stop MITM server": "ป้อนรหัสผ่าน sudo ของคุณเพื่อเริ่ม/หยุดเซิร์ฟเวอร์ MITM", - "Sudo Password": "รหัสผ่าน Sudo", + "Change Log": "บันทึกการเปลี่ยนแปลง", + "Changelog": "Changelog", + "Chat": "แชท", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "แชท / สร้างโค้ดผ่าน OpenAI หรือ Anthropic format พร้อม streaming", + "Chat Completions": "Chat Completions", + "Check": "ตรวจสอบ", + "Checking Claude CLI...": "กำลังตรวจสอบ Claude CLI...", + "Checking Claude Cowork...": "กำลังตรวจสอบ Claude Cowork...", + "Checking Cline...": "กำลังตรวจสอบ Cline...", + "Checking Codex CLI...": "กำลังตรวจสอบ Codex CLI...", + "Checking Copilot config...": "กำลังตรวจสอบ Copilot config...", + "Checking DeepSeek TUI...": "กำลังตรวจสอบ DeepSeek TUI...", + "Checking Factory Droid CLI...": "กำลังตรวจสอบ Factory Droid CLI...", + "Checking Hermes Agent...": "กำลังตรวจสอบ Hermes Agent...", + "Checking Kilo Code...": "กำลังตรวจสอบ Kilo Code...", + "Checking Open Claw CLI...": "กำลังตรวจสอบ Open Claw CLI...", + "Checking OpenCode CLI...": "กำลังตรวจสอบ OpenCode CLI...", + "Checking jcode CLI...": "กำลังตรวจสอบ jcode CLI...", + "Checking...": "กำลังตรวจสอบ...", + "Choose API Provider → Ollama": "เลือก API Provider → Ollama", + "Choose how to authenticate with GitLab Duo:": "เลือกวิธียืนยันตัวตนกับ GitLab Duo:", + "Choose your authentication method:": "เลือกวิธียืนยันตัวตน:", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - กำหนดค่าด้วยตนเอง", + "Claude CLI not detected locally": "ไม่พบ Claude CLI บนเครื่อง", + "Claude CLI not installed": "ไม่ได้ติดตั้ง Claude CLI", + "Claude Cowork - Manual Configuration": "Claude Cowork - กำหนดค่าด้วยตนเอง", + "Claude Desktop (Cowork mode) not detected": "ไม่พบ Claude Desktop (Cowork mode)", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork (inference จากบุคคลที่สาม)", + "Clear": "ล้าง", + "Clear (will use main model)": "ล้าง (จะใช้โมเดลหลัก)", + "Clear Filters": "ล้างตัวกรอง", + "Clear search": "ล้างการค้นหา", + "Click": "คลิก", + "Click \"View All Model\" → \"Add Custom Model\"": "คลิก \"View All Model\" → \"Add Custom Model\"", + "Click a model to set/clear active": "คลิกโมเดลเพื่อตั้งค่า/ยกเลิกสถานะใช้งาน", "Click to add, click again to remove. Changes are saved automatically.": "คลิกเพื่อเพิ่ม คลิกอีกครั้งเพื่อลบ การเปลี่ยนแปลงจะถูกบันทึกโดยอัตโนมัติ", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ ประกาศความเสี่ยง: ผู้ให้บริการนี้ใช้เซสชันสมัครสมาชิก/OAuth ที่ไม่ได้รับอนุญาตอย่างเป็นทางการสำหรับการใช้งานพร็อกซี/เราเตอร์ บัญชีอาจถูกจำกัดหรือถูกแบน ใช้งานด้วยความเสี่ยงของคุณเอง", - "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ดักจับการรับส่งข้อมูล HTTPS ของเครื่องมือ IDE (Antigravity, GitHub Copilot, Kiro) ผ่าน CA ท้องถิ่นเพื่อเปลี่ยนเส้นทางคำขอไปยังผู้ให้บริการของคุณ อาจละเมิด ToS → เสี่ยงถูกแบนบัญชี ใช้งานด้วยความเสี่ยงของคุณเอง", - "Endpoint is exposed without an API key.": "เอนด์พอยต์เปิดให้เข้าถึงโดยไม่มีคีย์ API" + "Click to edit": "คลิกเพื่อแก้ไข", + "Click to retry": "คลิกเพื่อลองใหม่", + "Client ID": "Client ID", + "Client Request": "คำขอจากลูกค้า", + "Client Response": "การตอบกลับลูกค้า", + "Client Secret": "Client Secret", + "Cline - Manual Configuration": "Cline - กำหนดค่าด้วยตนเอง", + "Cline AI Coding Assistant": "Cline AI Coding Assistant", + "Cline not detected locally": "ไม่พบ Cline บนเครื่อง", + "Close": "ปิด", + "Close Proxy": "ปิด Proxy", + "Close provider filter": "ปิดตัวกรองผู้ให้บริการ", + "Close reset credit expiry modal": "ปิดหน้าต่าง reset credit expiry", + "Close test results": "ปิดผลการทดสอบ", + "Closing in": "ปิดใน", + "Cloud Sync": "Cloud Sync", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Cloudflare Tunnel", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - กำหนดค่าด้วยตนเอง", + "Codex CLI not detected locally": "ไม่พบ Codex CLI บนเครื่อง", + "Codex CLI not installed": "ไม่ได้ติดตั้ง Codex CLI", + "Codex Reset Credit Expiry": "Reset Credit Expiry ของ Codex", + "Codex uses": "Codex ใช้", + "Combo Name": "ชื่อ Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Combo Sticky Limit", + "Combos": "Combos", + "Coming soon...": "เร็วๆ นี้...", + "Comma-separated hostnames/domains to bypass the proxy.": "โฮสต์/โดเมน คั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", + "Comma-separated hosts/domains to bypass proxy": "โฮสต์/โดเมน คั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", + "Company": "บริษัท", + "Complete the authorization in the popup window.": "ดำเนินการอนุมัติให้เสร็จสิ้นในหน้าต่างป๊อปอัป", + "Completion/response tokens": "Completion/Response tokens", + "Compress LLM output": "บีบอัด output ของ LLM", + "Compress context": "บีบอัดบริบท", + "Compress prompts via /v1/compress before routing to the model": "บีบอัด prompt ผ่าน /v1/compress ก่อนส่งไปยังโมเดล", + "Compress tool output": "บีบอัด tool output", + "Compress tool output to reduce token usage.": "บีบอัด tool output เพื่อลดการใช้งาน token", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "เส้นทาง Config: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "เส้นทาง Config: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "การกำหนดค่า", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "กำหนดค่า 9router เป็น OpenAI-compatible provider เพื่อส่งต่อคำขอ jcode ทั้งหมดผ่าน optimization layer ของ 9router", + "Configure CLI tools": "กำหนดค่าเครื่องมือ CLI", + "Configure a new AI provider to use with your applications.": "กำหนดค่า AI provider ใหม่เพื่อใช้กับแอปพลิเคชันของคุณ", + "Configure pricing rates for cost tracking and calculations": "กำหนดค่าอัตราการคิดราคาสำหรับการติดตามและคำนวณค่าใช้จ่าย", + "Configure providers and API keys via web interface": "กำหนดค่า providers และ API keys ผ่าน web interface", + "Configured": "กำหนดค่าแล้ว", + "Confirm": "ยืนยัน", + "Confirm New Password": "ยืนยันรหัสผ่านใหม่", + "Confirm Password": "ยืนยันรหัสผ่าน", + "Confirm new password": "ยืนยันรหัสผ่านใหม่", + "Connect": "เชื่อมต่อ", + "Connect AI tools remotely": "เชื่อมต่อเครื่องมือ AI จากที่ไกล", + "Connect Cursor IDE": "เชื่อมต่อ Cursor IDE", + "Connect GitLab Duo": "เชื่อมต่อ GitLab Duo", + "Connect Kiro": "เชื่อมต่อ Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "เชื่อมต่อกับ providers ด้วย OAuth เพื่อติดตาม API quota limits และการใช้งานของคุณ", + "Connect via OAuth or API keys. Securely manage credentials.": "เชื่อมต่อผ่าน OAuth หรือ API keys จัดการข้อมูลรับรองอย่างปลอดภัย", + "Connect with OAuth2": "เชื่อมต่อด้วย OAuth2", + "Connect your account using OAuth2 authentication.": "เชื่อมต่อบัญชีของคุณโดยใช้ OAuth2 authentication", + "Connected": "เชื่อมต่อแล้ว", + "Connected Successfully!": "เชื่อมต่อสำเร็จ!", + "Connected providers only": "เฉพาะ providers ที่เชื่อมต่อแล้ว", + "Connecting...": "กำลังเชื่อมต่อ...", + "Connection": "การเชื่อมต่อ", + "Connection Details": "รายละเอียดการเชื่อมต่อ", + "Connection Failed": "เชื่อมต่อล้มเหลว", + "Connections": "การเชื่อมต่อ", + "Console Log": "Console Log", + "Contact": "ติดต่อ", + "Content": "เนื้อหา", + "Continue": "ดำเนินการต่อ", + "Continue AI Assistant": "Continue AI Assistant", + "Continue to summary": "ดำเนินการต่อไปยังสรุป", + "Continue with GitHub": "ดำเนินการต่อด้วย GitHub", + "Continue with Google": "ดำเนินการต่อด้วย Google", + "Cookie": "Cookie", + "Cookie Auth": "Cookie Auth", + "Cookie String": "Cookie String", + "Cooldown": "พักเครื่อง", + "Copied!": "คัดลอกแล้ว!", + "Copy": "คัดลอก", + "Copy & Shutdown": "คัดลอกและปิดระบบ", + "Copy This URL": "คัดลอก URL นี้", + "Copy a link and paste to your AI to use 9Router — no install needed": "คัดลอกลิงก์แล้ววางให้ AI ของคุณเพื่อใช้ 9Router — ไม่ต้องติดตั้ง", + "Copy combo name": "คัดลอกชื่อ combo", + "Copy install command": "คัดลอกคำสั่งติดตั้ง", + "Copy model": "คัดลอกโมเดล", + "Copy the JSON below to your ~/.qwen/settings.json file.": "คัดลอก JSON ด้านล่างไปยังไฟล์ ~/.qwen/settings.json ของคุณ", + "Copy the entire cookie string (must include BXAuth)": "คัดลอก cookie string ทั้งหมด (ต้องมี BXAuth)", + "Cost": "ค่าใช้จ่าย", + "Cost Calculation:": "การคำนวณค่าใช้จ่าย:", + "Costs": "ค่าใช้จ่าย", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "ค่าใช้จ่ายคำนวณจาก token usage และ pricing rates ค่าใช้จ่ายของแต่ละคำขอคำนวณจาก: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", + "Could not read Cursor database automatically.": "ไม่สามารถอ่าน Cursor database โดยอัตโนมัติได้", + "Create": "สร้าง", + "Create API Key": "สร้าง API Key", + "Create Combo": "สร้าง Combo", + "Create Cowork Combo": "สร้าง Cowork Combo", + "Create Key": "สร้าง Key", + "Create Provider": "สร้าง Provider", + "Create Token": "สร้าง Token", + "Create a": "สร้าง", + "Create a proxy pool entry, then assign it to connections.": "สร้าง proxy pool entry แล้วมอบหมายให้กับการเชื่อมต่อ", + "Create model combos with fallback support": "สร้าง model combos ที่รองรับ fallback", + "Create your first API key to get started": "สร้าง API key แรกของคุณเพื่อเริ่มต้น", + "Created": "สร้างแล้ว", + "Creating...": "กำลังสร้าง...", + "Current": "ปัจจุบัน", + "Current Password": "รหัสผ่านปัจจุบัน", + "Current Pricing Overview": "ภาพรวม Pricing ปัจจุบัน", + "Current password": "รหัสผ่านปัจจุบัน", + "Current: Keeps": "ปัจจุบัน: คงไว้", + "Currently using accounts in priority order (Fill First).": "ใช้บัญชีตามลำดับความสำคัญ (เติมก่อน)", + "Cursor AI Code Editor": "Cursor AI Code Editor", + "Cursor IDE not detected. Please paste your tokens manually.": "ไม่พบ Cursor IDE กรุณาวาง tokens ด้วยตนเอง", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor ส่งต่อคำขอผ่านเซิร์ฟเวอร์ของตัวเอง จึงไม่รองรับ local endpoint กรุณาเปิดใช้งาน Tunnel หรือ Cloud Endpoint ในการตั้งค่า", + "Custom": "กำหนดเอง", + "Custom Pricing:": "กำหนดราคาเอง:", + "Custom Providers (OpenAI/Anthropic Compatible)": "Custom Providers (OpenAI/Anthropic Compatible)", + "Custom Token": "Custom Token", + "Custom accounts per page": "กำหนดจำนวนบัญชีต่อหน้าเอง", + "Custom providers": "Custom providers", + "Custom...": "กำหนดเอง...", + "Cycle through accounts to distribute load": "วนลูปบัญชีเพื่อกระจายโหลด", + "Cycle through providers in combos instead of always starting with first": "วนลูป providers ใน combos แทนที่จะเริ่มจากตัวแรกเสมอ", + "DNS off": "DNS ปิด", + "Dashboard": "แดชบอร์ด", + "Dashboard Password": "รหัสผ่านแดชบอร์ด", + "Dashboard:": "แดชบอร์ด:", + "Data Location:": "ตำแหน่งข้อมูล:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "ข้อมูลไหลอย่างราบรื่นจากแอปพลิเคชันของคุณผ่าน intelligent routing layer ไปยังผู้ให้บริการที่เหมาะสมที่สุด", + "Data flows seamlessly through our intelligent routing system": "ข้อมูลไหลอย่างราบรื่นผ่านระบบ intelligent routing ของเรา", + "Database Location": "ตำแหน่งฐานข้อมูล", + "Database backup downloaded": "ดาวน์โหลดฐานข้อมูลสำรองแล้ว", + "Database imported successfully": "นำเข้าฐานข้อมูลสำเร็จ", + "DateTime": "วันที่และเวลา", + "Deactivate": "ปิดใช้งาน", + "Debug": "ดีบัก", + "Debug translation flow between formats": "ดีบักการไหลของการแปลระหว่าง formats", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - กำหนดค่าด้วยตนเอง", + "DeepSeek TUI not detected locally": "ไม่พบ DeepSeek TUI บนเครื่อง", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI ใช้ ~/.deepseek/config.toml สำหรับการกำหนดค่า 9Router จะอัปเดต provider เป็น 'openai' mode พร้อม base_url, api_key และ model ของคุณ", + "DeepSeek Terminal Coding Agent (Rust TUI)": "DeepSeek Terminal Coding Agent (Rust TUI)", + "Default Model": "โมเดลเริ่มต้น", + "Default password is": "รหัสผ่านเริ่มต้นคือ", + "Default password is 123456": "รหัสผ่านเริ่มต้นคือ 123456", + "Delete": "ลบ", + "Delete API Key": "ลบ API Key", + "Delete connection": "ลบการเชื่อมต่อ", + "Delete saved endpoint": "ลบ endpoint ที่บันทึกไว้", + "Delete selected preset": "ลบ preset ที่เลือก", + "Delete this combo?": "ลบ combo นี้?", + "Delete this connection?": "ลบการเชื่อมต่อนี้?", + "Deno Deploy API Token": "Deno Deploy API Token", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 ทำงานบน高性能 global edge network", + "Deno Relay": "Deno Relay", + "Deploy": "deploy", + "Deploy Cloudflare Relay": "Deploy Cloudflare Relay", + "Deploy Deno Relay": "Deploy Deno Relay", + "Deploy Relay": "Deploy Relay", + "Deploy Vercel Relay": "Deploy Vercel Relay", + "Deploy multiple relays for maximum IP diversity": "deploy relays หลายตัวเพื่อความหลากหลายของ IP มากที่สุด", + "Deploy multiple relays on different accounts for more IP diversity": "deploy relays หลายตัวบนบัญชีต่างกันเพื่อความหลากหลายของ IP มากขึ้น", + "Deploying... (may take ~1 min)": "กำลัง deploy... (อาจใช้เวลาประมาณ 1 นาที)", + "Deployment Name": "ชื่อ deployment", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "deploy Cloudflare Worker เป็น proxy relay คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน global edge network ของ Cloudflare", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "deploy relay worker ไปยัง global edge network ของ Deno Deploy คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน Deno edge ปิดบัง IP จริงของคุณ", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "deploy edge relay function ไปยัง Vercel ที่ส่งต่อคำขอผ่านเครือข่าย Vercel", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "deploy edge relay function ไปยัง Vercel คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน edge network ของ Vercel ปิดบัง IP จริงของคุณจาก providers", + "Desktop": "เดสก์ท็อป", + "Detail": "รายละเอียด", + "Details": "รายละเอียด", + "Dimensions": "มิติ", + "Disable": "ปิดใช้งาน", + "Disable All": "ปิดทั้งหมด", + "Disable Tailscale": "ปิด Tailscale", + "Disable Tunnel": "ปิด Tunnel", + "Disable connections with depleted quota on the current page": "ปิดการเชื่อมต่อที่ quota หมดบนหน้าปัจจุบัน", + "Disable provider": "ปิด provider", + "Disable this model": "ปิดโมเดลนี้", + "Disabled": "ปิดใช้งานแล้ว", + "Disabling...": "กำลังปิด...", + "Disconnected from server": "ตัดการเชื่อมต่อจากเซิร์ฟเวอร์", + "Dismiss notification": "ปิดการแจ้งเตือน", + "Display Name": "ชื่อที่แสดง", + "Display language": "ภาษาที่แสดง", + "Docs": "เอกสาร", + "Documentation": "เอกสาร", + "Domain:": "โดเมน:", + "Donate": "บริจาค", + "Done": "เสร็จสิ้น", + "Download": "ดาวน์โหลด", + "Download Backup": "ดาวน์โหลดข้อมูลสำรอง", + "Drag to reorder": "ลากเพื่อจัดเรียงใหม่", + "Easy Setup": "ตั้งค่าง่าย", + "Edit": "แก้ไข", + "Edit Combo": "แก้ไข Combo", + "Edit Connection": "แก้ไขการเชื่อมต่อ", + "Edit Pricing": "แก้ไข Pricing", + "Edit Proxy Pool": "แก้ไข Proxy Pool", + "Edit connection": "แก้ไขการเชื่อมต่อ", + "Edit hosts file manually to add the following entries:": "แก้ไขไฟล์ hosts ด้วยตนเองเพื่อเพิ่มรายการต่อไปนี้:", + "Email": "อีเมล", + "Embedding": "Embedding", + "Embeddings": "Embeddings", + "Enable": "เปิดใช้งาน", + "Enable DNS per tool below to activate interception": "เปิดใช้งาน DNS สำหรับแต่ละเครื่องมือด้านล่างเพื่อเปิดใช้งานการดักจับ", + "Enable DNS to edit model mappings": "เปิดใช้งาน DNS เพื่อแก้ไข model mappings", + "Enable Observability": "เปิดใช้งาน Observability", + "Enable OpenAI API": "เปิดใช้งาน OpenAI API", + "Enable Tunnel": "เปิดใช้งาน Tunnel", + "Enable connections that still have quota on the current page": "เปิดการเชื่อมต่อที่ยังมี quota บนหน้าปัจจุบัน", + "Enable provider": "เปิด provider", + "Enable proxy for OAuth + provider outbound requests.": "เปิด proxy สำหรับ OAuth + provider outbound requests", + "Encrypted": "เข้ารหัสแล้ว", + "End Date": "วันที่สิ้นสุด", + "End-to-end TLS via Cloudflare": "TLS แบบ End-to-end ผ่าน Cloudflare", + "Endpoint": "Endpoint", + "Endpoint & Key": "Endpoint & Key", + "Endpoint is exposed without an API key.": "Endpoint เปิดให้เข้าถึงโดยไม่มี API key", + "Enter current password": "กรุณาป้อนรหัสผ่านปัจจุบัน", + "Enter model id": "กรุณาป้อน model id", + "Enter model id (provider-specific)": "กรุณาป้อน model id (เจาะจงผู้ให้บริการ)", + "Enter new API key": "ป้อน API key ใหม่", + "Enter new password": "ป้อนรหัสผ่านใหม่", + "Enter or pick API key": "ป้อนหรือเลือก API key", + "Enter password": "ป้อนรหัสผ่าน", + "Enter sudo password": "ป้อนรหัสผ่าน sudo", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "ป้อน model ID ตรงตามที่ compatible endpoint ของคุณต้องการ โมเดลนี้จะถูกบันทึกเป็นค่าเริ่มต้นของการเชื่อมต่อ", + "Enter your API key": "ป้อน API key ของคุณ", + "Enter your current password to": "ป้อนรหัสผ่านปัจจุบันเพื่อ", + "Enter your password to access the dashboard": "ป้อนรหัสผ่านเพื่อเข้าถึงแดชบอร์ด", + "Error": "ข้อผิดพลาด", + "Est. Cost": "ค่าใช้จ่ายโดยประมาณ", + "Estimated, not actual billing": "ค่าใช้จ่ายโดยประมาณ ไม่ใช่บิลจริง", + "Everything you need to manage your AI infrastructure efficiently.": "ทุกสิ่งที่คุณต้องการในการจัดการ AI infrastructure ของคุณอย่างมีประสิทธิภาพ", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "ทุกสิ่งที่คุณต้องการในการจัดการ AI infrastructure ในที่เดียว สร้างมาเพื่อรองรับขนาดใหญ่", + "Example": "ตัวอย่าง", + "Experimental": "ทดลองใช้", + "Expires At": "หมดอายุ", + "Expiring first": "ใกล้หมดอายุก่อน", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "การจัดเรียงแบบใกล้หมดอายุก่อนจะจัดเรียงบัญชีภายในหน้าปัจจุบัน การจัดเรียงข้ามหน้ายังคงใช้ pagination จาก backend", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "เปิด local 9Router ของคุณสู่อินเทอร์เน็ต ไม่ต้อง port forwarding ไม่ต้อง static IP แชร์ endpoint URL กับทีมหรือใช้ใน Cursor, Cline และเครื่องมือ AI อื่นๆ จากทุกที่", + "Factory Droid - Manual Configuration": "Factory Droid - กำหนดค่าด้วยตนเอง", + "Factory Droid AI Assistant": "Factory Droid AI Assistant", + "Factory Droid CLI not detected locally": "ไม่พบ Factory Droid CLI บนเครื่อง", + "Factory Droid CLI not installed": "ไม่ได้ติดตั้ง Factory Droid CLI", + "Fail request if proxy is unreachable instead of falling back to direct.": "ล้มเหลวคำขอเมื่อ proxy ไม่สามารถเข้าถึงได้แทนที่จะ fallback ไปยัง direct", + "Failed to apply settings": "ไม่สามารถใช้การตั้งค่าได้", + "Failed to create combo": "ไม่สามารถสร้าง combo ได้", + "Failed to load changelog:": "ไม่สามารถโหลด changelog ได้:", + "Failed to load usage statistics.": "ไม่สามารถโหลด usage statistics ได้", + "Failed to reset settings": "ไม่สามารถรีเซ็ตการตั้งค่าได้", + "Failed to set alias": "ไม่สามารถตั้ง alias ได้", + "Failed to update combo": "ไม่สามารถอัปเดต combo ได้", + "Failed to update password": "ไม่สามารถอัปเดตรหัสผ่านได้", + "Failed to update proxy settings": "ไม่สามารถอัปเดต proxy settings ได้", + "Fallback": "Fallback", + "Fallback — tries models in order (next on failure)": "Fallback — ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "Fallback — try in order": "Fallback — ลองตามลำดับ", + "Features": "คุณสมบัติ", + "Fetch Qoder Models": "ดึง Qoder Models", + "Fetching...": "กำลังดึงข้อมูล...", + "Files": "ไฟล์", + "Filter accounts by status": "กรองบัญชีตามสถานะ", + "Filter naming": "กรอง naming", + "Filter naming requests": "กรอง naming requests", + "Filter quota providers": "กรอง quota providers", + "Find MCPs →": "ค้นหา MCPs →", + "Find your Account ID in the right sidebar of": "ค้นหา Account ID ของคุณในแถบด้านขวาของ", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "ค้นหา Account ID ของคุณในแถบด้านขวาของ dash.cloudflare.com", + "First Page": "หน้าแรก", + "Flush Interval (ms)": "Flush Interval (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "สำหรับผู้ใช้ enterprise ที่มี AWS IAM Identity Center กำหนดเอง", + "Forgot password? Open": "ลืมรหัสผ่าน? เปิด", + "Format": "รูปแบบ", + "Found on the right side of the Cloudflare dashboard overview page.": "พบที่ด้านขวาของหน้าภาพรวม Cloudflare dashboard", + "Free": "ฟรี", + "Free & Free Tier Providers": "Free & Free Tier Providers", + "Free Providers": "Free Providers", + "Free Tier": "Free Tier", + "Free Tier Providers": "Free Tier Providers", + "Free tier: 100,000 requests per day": "Free tier: 100,000 requests ต่อวัน", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Free tier: 100GB bandwidth/เดือน, 500K edge invocations", + "Free tier: 1M requests & 100GiB outbound traffic per month": "Free tier: 1M requests & 100GiB outbound traffic ต่อเดือน", + "Fresh API key obtained": "ได้รับ API key ใหม่แล้ว", + "Full shell access": "Full shell access", + "Fusion": "Fusion", + "Fusion — panel + judge": "Fusion — panel + judge", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "Fusion — query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว", + "Get 9Remote": "รับ 9Remote", + "Get API Key": "รับ API Key", + "Get API Key →": "รับ API Key →", + "Get Started": "เริ่มต้นใช้งาน", + "Get Started in 30 Seconds": "เริ่มต้นใน 30 วินาที", + "Get started": "เริ่มต้น", + "Get started in seconds. Just install, open, and route.": "เริ่มต้นในไม่กี่วินาที ติดตั้ง เปิด และ route", + "Get token →": "รับ token →", + "GitHub": "GitHub", + "GitHub Account": "GitHub Account", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - กำหนดค่าด้วยตนเอง", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE พร้อม MITM", + "GitLab Access Tokens": "GitLab Access Tokens", + "GitLab Applications": "GitLab Applications", + "GitLab Base URL": "GitLab Base URL", + "Go to": "ไปที่", + "Go to Roo Settings panel": "ไปที่ Roo Settings panel", + "Google Account": "Google Account", + "Google Antigravity IDE with MITM": "Google Antigravity IDE พร้อม MITM", + "Granted At": "ให้สิทธิ์เมื่อ", + "Group models under one name, then pick a strategy per combo:": "รวมโมเดลภายใต้ชื่อเดียว แล้วเลือกกลยุทธ์สำหรับแต่ละ combo:", + "Headroom proxy is reachable. You can enable the token saver.": "Headroom proxy สามารถเข้าถึงได้ คุณสามารถเปิดใช้งาน token saver", + "Help Center": "ศูนย์ช่วยเหลือ", + "Hermes Agent - Manual Configuration": "Hermes Agent - กำหนดค่าด้วยตนเอง", + "Hermes Agent not detected locally": "ไม่พบ Hermes Agent บนเครื่อง", + "Hide": "ซ่อน", + "Hide key": "ซ่อน key", + "High performance global routing and IP masking via Cloudflare Workers": "High performance global routing และ IP masking ผ่าน Cloudflare Workers", + "High-performance Rust-based coding agent harness": "High-performance coding agent harness ที่สร้างด้วย Rust", + "History": "ประวัติ", + "How 9Router Works": "วิธีการทำงานของ 9Router", + "How Pricing Works": "วิธีการทำงานของ Pricing", + "How it Works": "วิธีการทำงาน", + "How it works:": "วิธีการทำงาน:", + "How to Install": "วิธีการติดตั้ง", + "How to generate API token:": "วิธีสร้าง API token:", + "How to generate your API Token:": "วิธีสร้าง API Token ของคุณ:", + "How to get cookie:": "วิธีรับ cookie:", + "ID:": "ID:", + "IDC Start URL": "IDC Start URL", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "หาก provider ไม่มี /models endpoint ให้ป้อน model ID เพื่อตรวจสอบผ่าน chat/completions แทน", + "Image Generation": "สร้างรูปภาพ", + "Image to Text": "รูปภาพเป็นข้อความ", + "Import": "นำเข้า", + "Import Backup": "นำเข้าข้อมูลสำรอง", + "Import CLIProxyAPI JSON": "นำเข้า CLIProxyAPI JSON", + "Import Token": "นำเข้า Token", + "Importing...": "กำลังนำเข้า...", + "In": "ขาเข้า", + "In / Out": "ขาเข้า / ขาออก", + "Inactive": "ไม่ใช้งาน", + "Inactive pools are ignored by runtime resolution.": "Inactive pools จะถูกเมินโดย runtime resolution", + "Inc. All rights reserved.": "Inc. สงวนลิขสิทธิ์", + "Initializing...": "กำลังเริ่มต้น...", + "Input": "ขาเข้า", + "Input Cost": "ค่าใช้จ่ายขาเข้า", + "Input Tokens": "Input Tokens", + "Input Tokens:": "Input Tokens:", + "Input:": "ขาเข้า:", + "Install 9Router": "ติดตั้ง 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "ติดตั้ง 9Router กำหนดค่า providers ผ่าน web dashboard แล้วเริ่ม routing AI requests", + "Install Chrome extension": "ติดตั้ง Chrome extension", + "Install Cline VS Code extension or CLI from": "ติดตั้ง Cline VS Code extension หรือ CLI จาก", + "Install Kilo Code from": "ติดตั้ง Kilo Code จาก", + "Install Qwen Code": "ติดตั้ง Qwen Code", + "Install Tailscale": "ติดตั้ง Tailscale", + "Install command:": "คำสั่งติดตั้ง:", + "Install jcode to enable automatic configuration:": "ติดตั้ง jcode เพื่อเปิดใช้งานการกำหนดค่าอัตโนมัติ:", + "Install the Amp CLI using the package manager supported by your environment.": "ติดตั้ง Amp CLI โดยใช้ package manager ที่รองรับในสภาพแวดล้อมของคุณ", + "Install then click Start:": "ติดตั้งแล้วคลิกเริ่ม:", + "Install via npm:": "ติดตั้งผ่าน npm:", + "Installation Guide": "คู่มือการติดตั้ง", + "Installing Tailscale...": "กำลังติดตั้ง Tailscale...", + "Interactive diagram visible on desktop": "แผนภาพแบบ interactive ที่มองเห็นบนเดสก์ท็อป", + "Intercept CLI tool traffic and route through 9Router": "ดักจับ CLI tool traffic แล้วส่งต่อผ่าน 9Router", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ดักจับ Antigravity traffic ผ่าน DNS redirect ช่วยให้คุณ reroute โมเดลผ่าน 9Router", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "ดักจับ topic-naming requests ของ Claude Code แล้วส่ง fake response กลับภายในเครื่อง ประหยัด API tokens", + "Invalid": "ไม่ถูกต้อง", + "Invalid password": "รหัสผ่านไม่ถูกต้อง", + "Issuer URL": "Issuer URL", + "JSON Response": "JSON Response", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "เข้าร่วมกับนักพัฒนาที่กำลังปรับปรุง AI integrations ของพวกเขาด้วย 9Router Open source และเริ่มต้นใช้งานฟรี", + "Judge": "Judge", + "Just now": "เมื่อสักครู่", + "KB per field": "KB ต่อฟิลด์", + "Keep the legacy password login.": "คงการเข้าสู่ระบบด้วยรหัสผ่านแบบเดิมไว้", + "Key Name": "ชื่อ Key", + "KiRo dashboard": "KiRo dashboard", + "Kill & Start": "หยุดและเริ่มใหม่", + "Kill this process to start MITM Server?": "หยุด process นี้เพื่อเริ่ม MITM Server?", + "Kilo Code - Manual Configuration": "Kilo Code - กำหนดค่าด้วยตนเอง", + "Kilo Code AI Assistant": "Kilo Code AI Assistant", + "Kilo Code not detected locally": "ไม่พบ Kilo Code บนเครื่อง", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "ไม่พบ Kiro IDE กรุณาวาง refresh token ด้วยตนเอง", + "Kiro IDE with MITM": "Kiro IDE พร้อม MITM", + "Language": "ภาษา", + "Languages": "ภาษา", + "Last Page": "หน้าสุดท้าย", + "Last Used": "ใช้ล่าสุด", + "Last tested:": "ทดสอบล่าสุด:", + "Last updated:": "อัปเดตล่าสุด:", + "Latency": "ความเร็ว", + "Latency:": "ความเร็ว:", + "Lazy senior dev": "senior dev ขี้เกียจ", + "Lean": "Lean", + "Leave blank to keep existing secret": "เว้นว่างไว้เพื่อคง secret ที่มีอยู่", + "Leave blank to use": "เว้นว่างไว้เพื่อใช้", + "Leave empty for public PKCE app": "เว้นว่างสำหรับ PKCE app สาธารณะ", + "Leave empty to inherit existing env proxy (if any).": "เว้นว่างไว้เพื่อสืบทอด env proxy ที่มีอยู่ (ถ้ามี)", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "Legacy manual proxy fields ยังคงได้รับการยอมรับจาก API เพื่อ backward compatibility", + "Legacy:": "เดิม:", + "Legal": "กฎหมาย", + "Live server console output": "Live server console output", + "Load": "โหลด", + "Loading logs...": "กำลังโหลด logs...", + "Loading models from provider...": "กำลังโหลดโมเดลจาก provider...", + "Loading pricing data...": "กำลังโหลด pricing data...", + "Loading registry...": "กำลังโหลด registry...", + "Loading reset credits...": "กำลังโหลด reset credits...", + "Loading...": "กำลังโหลด...", + "Local": "ท้องถิ่น", + "Local Mode": "Local Mode", + "Local Mode - All data stored on your machine": "Local Mode - ข้อมูลทั้งหมดจัดเก็บบนเครื่องของคุณ", + "Local Plugins": "Local Plugins", + "Locked. Retry in": "ล็อก. ลองใหม่ใน", + "Login": "เข้าสู่ระบบ", + "Login Button Label": "ป้ายปุ่มเข้าสู่ระบบ", + "Login URL": "Login URL", + "Login to your account": "เข้าสู่ระบบบัญชีของคุณ", + "Login with your GitHub account (manual callback).": "เข้าสู่ระบบด้วย GitHub account (manual callback)", + "Login with your Google account (manual callback).": "เข้าสู่ระบบด้วย Google account (manual callback)", + "Logout": "ออกจากระบบ", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs จะถูกโหลดจาก request history database", + "Logs are saved to log.txt in the application data directory.": "Logs จะถูกบันทึกลง log.txt ใน application data directory", + "MIT License": "MIT License", + "MITM": "MITM", + "MITM Proxy": "MITM Proxy", + "MITM Server": "MITM Server", + "MITM Tools": "MITM Tools", + "Machine ID": "Machine ID", + "Machine ID will be auto-filled...": "Machine ID จะถูกเติมอัตโนมัติ...", + "Make sure Cursor IDE has been opened at least once, then click": "ตรวจสอบว่า Cursor IDE เปิดอย่างน้อยหนึ่งครั้งแล้ว แล้วคลิก", + "Manage": "จัดการ", + "Manage reusable per-connection proxies and bind them to provider connections.": "จัดการ reusable per-connection proxies แล้ว bind เข้ากับ provider connections", + "Manage your AI provider connections": "จัดการการเชื่อมต่อ AI providers ของคุณ", + "Manage your Embedding providers": "จัดการ Embedding providers ของคุณ", + "Manage your Image to Text providers": "จัดการ Image to Text providers ของคุณ", + "Manage your Music providers": "จัดการ Music providers ของคุณ", + "Manage your Speech To Text providers": "จัดการ Speech To Text providers ของคุณ", + "Manage your Text To Speech providers": "จัดการ Text To Speech providers ของคุณ", + "Manage your Text to Image providers": "จัดการ Text to Image providers ของคุณ", + "Manage your Video providers": "จัดการ Video providers ของคุณ", + "Manage your Web Fetch providers": "จัดการ Web Fetch providers ของคุณ", + "Manage your Web Search providers": "จัดการ Web Search providers ของคุณ", + "Manage your preferences": "จัดการการตั้งค่าของคุณ", + "Manage your proxy pool configurations": "จัดการการกำหนดค่า proxy pool ของคุณ", + "Manual / current endpoint": "Manual / current endpoint", + "Manual Callback Required": "ต้องใช้ Manual Callback", + "Manual Config": "กำหนดค่าด้วยตนเอง", + "Manual configuration is still available if 9router is deployed on a remote server.": "การกำหนดค่าด้วยตนเองยังใช้ได้หาก 9router ถูก deploy บน remote server", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "Map Amp shorthand names เช่น g25p หรือ cs45 ไปยัง 9Router aliases ในการกำหนดค่าท้องถิ่นของคุณ", + "Mask (URL)": "Mask (URL)", + "Max JSON Size (KB)": "Max JSON Size (KB)", + "Max Records": "Max Records", + "Maximum request detail records to keep (older records are auto-deleted)": "จำนวน request detail records สูงสุดที่จะเก็บ (records เก่าจะถูกลบอัตโนมัติ)", + "Maximum size for each JSON field (request/response) before truncation": "ขนาดสูงสุดสำหรับ JSON field แต่ละตัว (request/response) ก่อนถูกตัด", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "เวลาสูงสุดที่จะรอก่อน flush buffer (ป้องกันข้อมูลสูญหายในช่วง traffic ต่ำ)", + "Media Providers": "Media Providers", + "Menu": "เมนู", + "Message AI": "ส่งข้อความหา AI", + "Messages": "ข้อความ", + "Messages API": "Messages API", + "MiniMax": "MiniMax", + "Model": "โมเดล", + "Model Fallback": "Model Fallback", + "Model ID": "Model ID", + "Model ID (from OpenRouter)": "Model ID (จาก OpenRouter)", + "Model ID (optional)": "Model ID (ไม่บังคับ)", + "Model Status": "สถานะโมเดล", + "Model combos": "Model combos", + "Model combos with fallback": "Model combos พร้อม fallback", + "Model is reachable": "โมเดลเข้าถึงได้", + "Model list is filtered from connected providers.": "รายชื่อโมเดลถูกกรองจาก connected providers", + "Model mappings will be available soon.": "Model mappings จะพร้อมใช้งานเร็วๆ นี้", + "Model not reachable": "โมเดลเข้าถึงไม่ได้", + "Model:": "โมเดล:", + "Models": "โมเดล", + "Monitor your API usage, token consumption, and request logs": "ตรวจสอบ API usage, token consumption และ request logs ของคุณ", + "More on GitHub": "ดูเพิ่มเติมบน GitHub", + "Move down": "เลื่อนลง", + "Move up": "เลื่อนขึ้น", + "Music": "เพลง", + "My Profile": "โปรไฟล์ของฉัน", + "N/A": "ไม่มี", + "NPM": "NPM", + "Name": "ชื่อ", + "Name is required": "ต้องระบุชื่อ", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "รองรับ CLI tools อย่างเป็นทางการสำหรับ Cursor, Claude, Copilot และอื่นๆ", + "Navigate to home": "ไปที่หน้าแรก", + "Network": "เครือข่าย", + "Network Error": "เครือข่ายขัดข้อง", + "Network error": "เครือข่ายขัดข้อง", + "Never": "ไม่เคย", + "New Password": "รหัสผ่านใหม่", + "New password": "รหัสผ่านใหม่", + "Next": "ถัดไป", + "Next accounts page": "หน้าบัญชีถัดไป", + "No API keys - Create one in Keys page": "ยังไม่มี API keys - สร้างในหน้า Keys", + "No API keys yet": "ยังไม่มี API keys", + "No MCPs added": "ยังไม่ได้เพิ่ม MCPs", + "No Providers Connected": "ยังไม่มี Providers เชื่อมต่อ", + "No Proxy": "ไม่มี Proxy", + "No active connections found for this group.": "ไม่พบการเชื่อมต่อที่ใช้งานอยู่สำหรับกลุ่มนี้", + "No active providers": "ไม่มี providers ที่ใช้งานอยู่", + "No active proxy pools available. Create one in Proxy Pools page first.": "ไม่มี proxy pools ที่ใช้งานอยู่ สร้างในหน้า Proxy Pools ก่อน", + "No authentication required": "ไม่ต้องยืนยันตัวตน", + "No combos yet": "ยังไม่มี combos", + "No combos yet.": "ยังไม่มี combos", + "No compatible providers added yet": "ยังไม่ได้เพิ่ม compatible providers", + "No connections": "ไม่มีการเชื่อมต่อ", + "No connections yet": "ยังไม่มีการเชื่อมต่อ", + "No console logs yet.": "ยังไม่มี console logs", + "No conversations yet.": "ยังไม่มีการสนทนา", + "No custom providers": "ไม่มี custom providers", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "ไม่มี custom providers — ใช้ปุ่มด้านบนเพื่อเพิ่ม OpenAI/Anthropic compatible endpoints", + "No data for this period": "ไม่มีข้อมูลสำหรับช่วงเวลานี้", + "No key configured": "ยังไม่ได้กำหนดค่า key", + "No language selected": "ยังไม่ได้เลือกภาษา", + "No languages found.": "ไม่พบภาษา", + "No logs recorded yet.": "ยังไม่มี logs บันทึกไว้", + "No model selected.": "ยังไม่ได้เลือกโมเดล", + "No models": "ยังไม่มีโมเดล", + "No models added yet": "ยังไม่ได้เพิ่มโมเดล", + "No models configured": "ยังไม่ได้กำหนดค่าโมเดล", + "No models found": "ไม่พบโมเดล", + "No models match your filter.": "ไม่มีโมเดลที่ตรงกับตัวกรองของคุณ", + "No models selected": "ยังไม่ได้เลือกโมเดล", + "No port forwarding needed": "ไม่ต้อง port forwarding", + "No pricing data available": "ไม่มี pricing data ที่พร้อมใช้งาน", + "No providers connected": "ยังไม่มี providers เชื่อมต่อ", + "No providers match your search": "ไม่มี providers ที่ตรงกับการค้นหาของคุณ", + "No providers support": "ไม่มี providers รองรับ", + "No providers yet.": "ยังไม่มี providers", + "No providers.": "ไม่มี providers", + "No proxy pool entries yet": "ยังไม่มี proxy pool entries", + "No proxy:": "ไม่มี proxy:", + "No quota data available": "ไม่มี quota data ที่พร้อมใช้งาน", + "No request details found": "ไม่พบ request details", + "No requests yet.": "ยังไม่มี requests", + "No reset credit details returned for this account.": "ไม่มี reset credit details สำหรับบัญชีนี้", + "No results": "ไม่มีผลลัพธ์", + "No servers match filter": "ไม่มีเซิร์ฟเวอร์ที่ตรงกับตัวกรอง", + "No tools advertised by server.": "เซิร์ฟเวอร์ไม่ได้โฆษณาเครื่องมือใดๆ", + "No usage yet.": "ยังไม่มีการใช้งาน", + "None": "ไม่มี", + "None (unbind all)": "ไม่มี (unbind ทั้งหมด)", + "Not configured": "ยังไม่ได้กำหนดค่า", + "Not installed": "ไม่ได้ติดตั้ง", + "Notice": "ประกาศ", + "Nous Research self-improving AI agent": "Nous Research self-improving AI agent", + "Number of items to accumulate before writing to database (higher = better performance)": "จำนวนรายการที่สะสมก่อนเขียนลง database (ยิ่งมาก = ประสิทธิภาพยิ่งดี)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth & API Keys", + "OAuth Account": "OAuth Account", + "OAuth App": "OAuth App", + "OAuth Providers": "OAuth Providers", + "OAuth required": "ต้องใช้ OAuth", + "OIDC Dashboard Login": "OIDC Dashboard Login", + "OIDC active": "OIDC ใช้งานอยู่", + "OIDC login is currently active. Password login is disabled until you switch back.": "OIDC login ใช้งานอยู่ในขณะนี้ การเข้าสู่ระบบด้วยรหัสผ่านจะปิดอยู่จนกว่าจะสลับกลับ", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "OIDC login เปิดใช้งานแล้ว แต่ issuer/client fields ยังไม่ได้กำหนดค่า การเข้าสู่ระบบด้วยรหัสผ่านยังใช้ได้สำหรับการกู้คืน", + "OIDC only": "OIDC เท่านั้น", + "Observability": "Observability", + "Office Proxy": "Office Proxy", + "Ollama Host URL": "Ollama Host URL", + "One Endpoint for": "One Endpoint สำหรับ", + "One key per line. Format:": "หนึ่ง key ต่อบรรทัด รูปแบบ:", + "One-to-one (rotate)": "One-to-one (rotate)", + "Only from connected providers": "เฉพาะจาก connected providers", + "Only letters, numbers, - and _ allowed": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, - และ _", + "Only letters, numbers, -, _ and .": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, -, _ และ .", + "Only letters, numbers, -, _ and . allowed": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, -, _ และ .", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "อนุญาตหนึ่งการเชื่อมต่อต่อ compatible node หากต้องการการเชื่อมต่อเพิ่มเติม ให้เพิ่ม node อีกตัว", + "Open": "เปิด", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "เปิด Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference แล้วกลับมาที่นี่", + "Open Claw - Manual Configuration": "Open Claw - กำหนดค่าด้วยตนเอง", + "Open Claw AI Assistant": "Open Claw AI Assistant", + "Open Claw CLI not detected locally": "ไม่พบ Open Claw CLI บนเครื่อง", + "Open Claw CLI not installed": "ไม่ได้ติดตั้ง Open Claw CLI", + "Open Continue configuration file": "เปิด Continue configuration file", + "Open Dashboard": "เปิด Dashboard", + "Open DevTools (F12) → Application/Storage → Cookies": "เปิด DevTools (F12) → Application/Storage → Cookies", + "Open Settings": "เปิดการตั้งค่า", + "Open platform.iflow.cn in your browser": "เปิด platform.iflow.cn ในเบราว์เซอร์ของคุณ", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "OpenAI / ElevenLabs / Edge / Google / Deepgram voices", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "OpenAI Compatible (Production)", + "OpenAI Compatible Details": "รายละเอียด OpenAI Compatible", + "OpenAI Intermediate": "OpenAI Intermediate", + "OpenAI Response": "OpenAI Response", + "OpenCode - Manual Configuration": "OpenCode - กำหนดค่าด้วยตนเอง", + "OpenCode AI Terminal Assistant": "OpenCode AI Terminal Assistant", + "OpenCode CLI not detected locally": "ไม่พบ OpenCode CLI บนเครื่อง", + "OpenCode CLI not installed": "ไม่ได้ติดตั้ง OpenCode CLI", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter รองรับโมเดลใดก็ได้ เพิ่มโมเดลและสร้าง aliases เพื่อเข้าถึงอย่างรวดเร็ว", + "Optional SSO via Authentik/Keycloak/Google": "SSO ทางเลือกผ่าน Authentik/Keycloak/Google", + "Or paste callback URL manually": "หรือวาง callback URL ด้วยตนเอง", + "Organization": "องค์กร", + "Organization Domain": "Organization Domain", + "Organization ID": "Organization ID", + "Organization Token": "Organization Token", + "Organization Tokens": "Organization Tokens", + "Other": "อื่นๆ", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "engine ของเราจะวิเคราะห์ prompt แล้วส่งต่อผ่าน subscription, cheap และ free provider tiers ของคุณ พร้อม automatic fallback", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "engine ของเราจะวิเคราะห์ prompt ตรวจสอบ provider health แล้ว route ไปยัง latency ต่ำสุดหรือค่าใช้จ่ายน้อยที่สุด", + "Out": "ขาออก", + "Outbound Proxy": "Outbound Proxy", + "Output": "ขาออก", + "Output Cost": "ค่าใช้จ่ายขาออก", + "Output Format": "Output Format", + "Output Tokens": "Output Tokens", + "Output Tokens:": "Output Tokens:", + "Output:": "ขาออก:", + "Overview": "ภาพรวม", + "Paid": "เสียเงิน", + "Partial preview": "ตัวอย่างบางส่วน", + "Password": "รหัสผ่าน", + "Password + OIDC active": "รหัสผ่าน + OIDC ใช้งานอยู่", + "Password and OIDC login are both active.": "รหัสผ่านและ OIDC login ใช้งานอยู่ทั้งคู่", + "Password and OIDC login are both enabled.": "รหัสผ่านและ OIDC login เปิดใช้งานทั้งคู่", + "Password only": "เฉพาะรหัสผ่าน", + "Password updated successfully": "อัปเดตรหัสผ่านสำเร็จ", + "Passwords do not match": "รหัสผ่านไม่ตรงกัน", + "Paste Proxy List (One per line)": "วาง Proxy List (หนึ่งต่อบรรทัด)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "วาง Kiro/CodeWhisperer API key ที่มีอายุการใช้งานยาวนาน จะถูกตรวจสอบกับ AWS แล้วจัดเก็บโดยตรงเป็น bearer credential (ไม่ต้องรีเฟรช)", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "วาง external_idp auth JSON จาก CLIProxyAPI/Kiro Microsoft login", + "Paste it below": "วางด้านล่าง", + "Paste refresh token from Kiro IDE.": "วาง refresh token จาก Kiro IDE", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "วาง Kiro CLIProxyAPI auth JSON ที่มี auth_method=external_idp จะยอมรับเฉพาะ Microsoft login token endpoints", + "Paste the URL from your browser address bar": "วาง URL จาก address bar ของเบราว์เซอร์", + "Paste the command into your terminal and press Enter.": "วางคำสั่งลงใน terminal แล้วกด Enter", + "Paste this to your AI:": "วางสิ่งนี้ให้ AI ของคุณ:", + "Paste your Kiro API key...": "วาง Kiro API key ของคุณ...", + "Pause API Key": "พัก API Key", + "Pause key": "พัก key", + "Paused": "หยุดชั่วคราว", + "Permissions": "สิทธิ์", + "Personal Access Token": "Personal Access Token", + "Pick the model that fuses panel answers": "เลือกโมเดลที่ fuse panel answers", + "Please add an active Qoder connection first": "กรุณาเพิ่ม Qoder connection ที่ใช้งานอยู่ก่อน", + "Please add and connect providers first to configure CLI tools.": "กรุณาเพิ่มและเชื่อมต่อ providers ก่อนเพื่อกำหนดค่า CLI tools", + "Please copy the URL from the address bar and paste it in the application.": "กรุณาคัดลอก URL จาก address bar แล้ววางในแอปพลิเคชัน", + "Please enter a Proxy URL to test": "กรุณาป้อน Proxy URL เพื่อทดสอบ", + "Please install Claude CLI to use this feature.": "กรุณาติดตั้ง Claude CLI เพื่อใช้คุณสมบัตินี้", + "Please install Codex CLI to use auto-apply feature.": "กรุณาติดตั้ง Codex CLI เพื่อใช้คุณสมบัติ auto-apply", + "Please install Factory Droid CLI to use this feature.": "กรุณาติดตั้ง Factory Droid CLI เพื่อใช้คุณสมบัตินี้", + "Please install Open Claw CLI to use this feature.": "กรุณาติดตั้ง Open Claw CLI เพื่อใช้คุณสมบัตินี้", + "Please install OpenCode CLI to use auto-apply feature.": "กรุณาติดตั้ง OpenCode CLI เพื่อใช้คุณสมบัติ auto-apply", + "Please wait while we complete the authorization.": "กรุณารอในขณะที่เราดำเนินการอนุมัติให้เสร็จสิ้น", + "Point your CLI tools to http://localhost:20128": "ชี้ CLI tools ของคุณไปที่ http://localhost:20128", + "Pool:": "Pool:", + "Popup blocked? Enter URL manually": "ป๊อปอัปถูกบล็อก? ป้อน URL ด้วยตนเอง", + "Port 443 Already In Use": "Port 443 ถูกใช้งานอยู่แล้ว", + "Port 443 is currently used by another process:": "Port 443 ถูกใช้งานโดย process อื่นอยู่ในขณะนี้:", + "Powerful Features": "คุณสมบัติที่ทรงพลัง", + "Prefix": "Prefix", + "Preset": "Preset", + "Prev": "ก่อนหน้า", + "Preview": "ตัวอย่าง", + "Previous accounts page": "หน้าบัญชีก่อนหน้า", + "Pricing": "Pricing", + "Pricing Configuration": "การกำหนดค่า Pricing", + "Pricing Format:": "Pricing Format:", + "Pricing Rates Format": "Pricing Rates Format", + "Pricing Settings": "Pricing Settings", + "Priority": "ลำดับความสำคัญ", + "Privacy Policy": "นโยบายความเป็นส่วนตัว", + "Probing server for tools...": "กำลังตรวจสอบเซิร์ฟเวอร์สำหรับเครื่องมือ...", + "Processing...": "กำลังประมวลผล...", + "Product": "ผลิตภัณฑ์", + "Production Key": "Production Key", + "Project Name": "ชื่อโครงการ", + "Prompt": "Prompt", + "Provider": "Provider", + "Provider Details": "รายละเอียด Provider", + "Provider Limits": "Provider Limits", + "Provider Response": "Provider Response", + "Provider not found": "ไม่พบ Provider", + "Provider test failed": "ทดสอบ Provider ล้มเหลว", + "Provider:": "Provider:", + "Providers": "Providers", + "Proxy": "Proxy", + "Proxy Action": "Proxy Action", + "Proxy Pool": "Proxy Pool", + "Proxy Pools": "Proxy Pools", + "Proxy URL": "Proxy URL", + "Proxy disabled": "ปิด Proxy แล้ว", + "Proxy enabled": "เปิด Proxy แล้ว", + "Proxy pool created": "สร้าง Proxy Pool แล้ว", + "Proxy pool deleted": "ลบ Proxy Pool แล้ว", + "Proxy pool updated": "อัปเดต Proxy Pool แล้ว", + "Proxy settings applied": "ใช้ Proxy settings แล้ว", + "Proxy test OK": "ทดสอบ Proxy สำเร็จ", + "Proxy test failed": "ทดสอบ Proxy ล้มเหลว", + "Proxy test passed": "ทดสอบ Proxy ผ่าน", + "Purpose:": "วัตถุประสงค์:", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "ต้องใช้ Python >= 3.10 สำหรับ local managed mode กรุณาติดตั้ง Python ก่อน หรือใช้ external proxy URL", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "ต้องใช้ Python ≥ 3.10 สำหรับ local managed mode กรุณาติดตั้ง Python ก่อน หรือใช้ external proxy URL", + "Quota Tracker": "Quota Tracker", + "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code รองรับ provider types หลายประเภท (openai, anthropic, gemini) ผ่าน modelProviders ใน settings.json 9Router ทำงานเป็น OpenAI-compatible endpoint", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "Qwen OAuth free tier ถูกยกเลิกเมื่อ 15 เมษายน 2569 ให้ใช้ 9Router กับ alicode/openrouter/anthropic/gemini providers แทน", + "Rate Limited": "ถูกจำกัดอัตรา", + "Read Documentation": "อ่านเอกสาร", + "Reading from AWS SSO cache": "อ่านจาก AWS SSO cache", + "Reading from Cursor IDE database": "อ่านจาก Cursor IDE database", + "Ready": "พร้อม", + "Ready to Simplify Your AI Infrastructure?": "พร้อมที่จะทำให้ AI infrastructure ของคุณง่ายขึ้น?", + "Ready to route! ✓": "พร้อม route แล้ว! ✓", + "Ready! Requests route automatically through your configured providers.": "พร้อมแล้ว! Requests จะ route อัตโนมัติผ่าน providers ที่คุณกำหนดค่าไว้", + "Reasoning": "Reasoning", + "Reasoning:": "Reasoning:", + "Recent Requests": "Requests ล่าสุด", + "Recent chats": "แชทล่าสุด", + "Recheck": "ตรวจสอบอีกครั้ง", + "Recommended for most users. Free AWS account required.": "แนะนำสำหรับผู้ใช้ส่วนใหญ่ ต้องใช้ AWS account ฟรี", + "Record request details for inspection in the logs view": "บันทึก request details สำหรับตรวจสอบใน logs view", + "Redirect URI": "Redirect URI", + "Ref Image (URL)": "Ref Image (URL)", + "Refresh": "รีเฟรช", + "Refresh All": "รีเฟรชทั้งหมด", + "Refresh Token": "Refresh Token", + "Refresh all": "รีเฟรชทั้งหมด", + "Refresh quota": "รีเฟรช quota", + "Region": "Region", + "Reload Page": "โหลดหน้าใหม่", + "Reload VS Code after applying for changes to take effect.": "โหลด VS Code ใหม่หลังจากใช้เพื่อให้การเปลี่ยนแปลงมีผล", + "Remaining": "เหลือ", + "Remote": "ระยะไกล", + "Remove": "นำออก", + "Remove attachment": "ลบไฟล์แนบ", + "Remove custom model": "ลบ custom model", + "Remove model": "ลบโมเดล", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "แทนที่ built-in WebSearch/WebFetch จะลบรายการซ้ำจาก tool list โดยอัตโนมัติ", + "Replay request flow — matches log files": "เล่นซ้ำ request flow — ตรงกับ log files", + "Request": "Request", + "Request Details": "Request Details", + "Request Logs": "Request Logs", + "Requests": "Requests", + "Requests without a valid key will be rejected": "Requests ที่ไม่มี key ที่ถูกต้องจะถูกปฏิเสธ", + "Require API key": "ต้องใช้ API key", + "Require OIDC for dashboard access.": "ต้องใช้ OIDC เพื่อเข้าถึง dashboard", + "Require login": "ต้องเข้าสู่ระบบ", + "Required for SSL certificate and DNS configuration": "ต้องใช้สำหรับ SSL certificate และ DNS configuration", + "Required for SSL certificate and server startup": "ต้องใช้สำหรับ SSL certificate และ server startup", + "Required to modify /etc/hosts and flush DNS cache": "ต้องใช้สำหรับแก้ไข /etc/hosts และ flush DNS cache", + "Required. A friendly label for this node.": "จำเป็น ป้ายที่อ่านง่ายสำหรับ node นี้", + "Required. Used as the provider prefix for model IDs.": "จำเป็น ใช้เป็น provider prefix สำหรับ model IDs", + "Requires \"Workers Scripts: Edit\" permission.": "ต้องใช้สิทธิ์ \"Workers Scripts: Edit\"", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "ต้องใช้ Cloudflare Account ID และ Workers API Token (สิทธิ์ Edit Workers)", + "Requires Cursor Pro account to use this feature.": "ต้องใช้ Cursor Pro account เพื่อใช้คุณสมบัตินี้", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "ต้องติดตั้ง jcode ติดตั้งผ่าน: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "ต้องใช้ outbound port 7844 (TCP/UDP) การเชื่อมต่ออาจใช้เวลา 10-30 วินาที", + "Reset": "รีเซ็ต", + "Reset Codex limit?": "รีเซ็ต Codex limit?", + "Reset Password to Default": "รีเซ็ตรหัสผ่านเป็นค่าเริ่มต้น", + "Reset judge to Auto": "รีเซ็ต judge เป็น Auto", + "Reset time": "เวลาที่รีเซ็ต", + "Reset to Defaults": "รีเซ็ตเป็นค่าเริ่มต้น", + "Reset to default": "รีเซ็ตเป็นค่าเริ่มต้น", + "Resources": "ทรัพยากร", + "Response": "Response", + "Response Format": "Response Format", + "Responses": "Responses", + "Responses API": "Responses API", + "Restart": "เริ่มต้นใหม่", + "Restore model": "กู้คืนโมเดล", + "Resume key": "Resume key", + "Retry": "ลองใหม่", + "Risk Notice": "ประกาศความเสี่ยง", + "Roo AI Assistant": "Roo AI Assistant", + "Rotate providers across requests instead of strict fallback order.": "หมุนเวียน providers ผ่าน requests แทนที่จะใช้ fallback order อย่างเคร่งครัด", + "Round Robin": "Round Robin", + "Round Robin — rotate": "Round Robin — หมุนเวียน", + "Round Robin — rotates models across requests to spread load": "Round Robin — หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "Route AI requests ผ่าน subscription, cheap และ free tiers พร้อม auto-fallback One endpoint สำหรับ Claude, GPT, Gemini และอื่นๆ", + "Route Requests": "Route Requests", + "Routing Strategy": "Routing Strategy", + "Rows:": "Rows:", + "Run": "รัน", + "Run npx command to start the server instantly": "รันคำสั่ง npx เพื่อเริ่มต้นเซิร์ฟเวอร์ทันที", + "Run this command in your terminal, then click": "รันคำสั่งนี้ใน terminal ของคุณ แล้วคลิก", + "Running": "กำลังทำงาน", + "Running on your machine": "ทำงานบนเครื่องของคุณ", + "Runtime": "Runtime", + "SSE URL": "SSE URL", + "START HERE": "เริ่มที่นี่", + "Save": "บันทึก", + "Save Changes": "บันทึกการเปลี่ยนแปลง", + "Save Config": "บันทึก Config", + "Save Mappings": "บันทึก Mappings", + "Save auth mode": "บันทึกโหมดยืนยันตัวตน", + "Save current Base URL and API key as a browser-local preset": "บันทึก Base URL และ API key ปัจจุบันเป็น preset ที่เก็บในเบราว์เซอร์", + "Save this key now!": "บันทึก key นี้ตอนนี้!", + "Saved": "บันทึกแล้ว", + "Saving": "กำลังบันทึก", + "Saving...": "กำลังบันทึก...", + "Scan QR to connect instantly": "สแกน QR เพื่อเชื่อมต่อทันที", + "Scopes": "Scopes", + "Screen sharing": "แชร์หน้าจอ", + "Scroll down to": "เลื่อนลงมาที่", + "Search by name or description...": "ค้นหาตามชื่อหรือคำอธิบาย...", + "Search language...": "ค้นหาภาษา...", + "Search model id": "ค้นหา model id", + "Search providers...": "ค้นหา providers...", + "Search...": "ค้นหา...", + "Security": "ความปลอดภัย", + "Security required: ": "ต้องใช้ความปลอดภัย:", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "ความเสี่ยงด้านความปลอดภัย: ไม่ได้ตั้งรหัสผ่าน คุณจะถูกถามให้ตั้งรหัสผ่านเมื่อเข้าสู่ระบบจากที่ไกล", + "Select": "เลือก", + "Select All": "เลือกทั้งหมด", + "Select Cowork Model": "เลือก Cowork Model", + "Select Endpoint": "เลือก Endpoint", + "Select Judge Model": "เลือก Judge Model", + "Select Language": "เลือกภาษา", + "Select Model": "เลือกโมเดล", + "Select Model for Cline": "เลือกโมเดลสำหรับ Cline", + "Select Model for Codex": "เลือกโมเดลสำหรับ Codex", + "Select Model for DeepSeek TUI": "เลือกโมเดลสำหรับ DeepSeek TUI", + "Select Model for Factory Droid": "เลือกโมเดลสำหรับ Factory Droid", + "Select Model for GitHub Copilot": "เลือกโมเดลสำหรับ GitHub Copilot", + "Select Model for Hermes Agent": "เลือกโมเดลสำหรับ Hermes Agent", + "Select Model for Kilo Code": "เลือกโมเดลสำหรับ Kilo Code", + "Select Model for Open Claw": "เลือกโมเดลสำหรับ Open Claw", + "Select Model for OpenCode": "เลือกโมเดลสำหรับ OpenCode", + "Select Model for jcode": "เลือกโมเดลสำหรับ jcode", + "Select Provider": "เลือก Provider", + "Select Subagent Model for Codex": "เลือก Subagent Model สำหรับ Codex", + "Select Subagent Model for OpenCode": "เลือก Subagent Model สำหรับ OpenCode", + "Select a provider": "เลือก provider", + "Select all": "เลือกทั้งหมด", + "Select language": "เลือกภาษา", + "Select models to add": "เลือกโมเดลที่ต้องการเพิ่ม", + "Select one or more connections, then click Proxy Action.": "เลือกการเชื่อมต่อหนึ่งรายการขึ้นไป แล้วคลิก Proxy Action", + "Select to pre-fill, then edit model ID in the input": "เลือกเพื่อเติมล่วงหน้า แล้วแก้ไข model ID ในช่องป้อน", + "Select your": "เลือก", + "Selected connections have mixed proxy bindings": "การเชื่อมต่อที่เลือกมี proxy bindings ที่แตกต่างกัน", + "Selected only": "เฉพาะที่เลือก", + "Selected provider": "Provider ที่เลือก", + "Selecting None will unbind selected connections from proxy pool.": "เลือก None จะ unbind selected connections จาก proxy pool", + "Send": "ส่ง", + "Send to Provider": "ส่งไปยัง Provider", + "Sent to provider as:": "ส่งไปยัง Provider ในรูปแบบ:", + "Server": "เซิร์ฟเวอร์", + "Server Disconnected": "เซิร์ฟเวอร์ตัดการเชื่อมต่อ", + "Server off": "ปิดเซิร์ฟเวอร์", + "Server running on": "เซิร์ฟเวอร์ทำงานบน", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "บริการกำลังทำงานใน terminal คุณสามารถปิดหน้าเว็บนี้ได้ การปิดระบบจะหยุดบริการ", + "Set Password": "ตั้งรหัสผ่าน", + "Set a new password before accessing the dashboard remotely.": "ตั้งรหัสผ่านใหม่ก่อนเข้าถึง dashboard จากที่ไกล", + "Set password": "ตั้งรหัสผ่าน", + "Setting password for the first time. Leave current password empty or use default:": "ตั้งรหัสผ่านครั้งแรก ปล่อยรหัสผ่านปัจจุบันว่างหรือใช้ค่าเริ่มต้น:", + "Setting up": "กำลังตั้งค่า", + "Settings": "การตั้งค่า", + "Settings applied successfully!": "ใช้การตั้งค่าสำเร็จ!", + "Settings reset successfully!": "รีเซ็ตการตั้งค่าสำเร็จ!", + "Setup": "ตั้งค่า", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "การตั้งค่า + index ของทุกคุณสมบัติ เริ่มที่นี่ — ครอบคลุม base URL, auth, model discovery และลิงก์ไปยังทุกคุณสมบัติ", + "Share Endpoint": "แชร์ Endpoint", + "Share URL with team members": "แชร์ URL กับสมาชิกทีม", + "Show": "แสดง", + "Show all": "แสดงทั้งหมด", + "Show key": "แสดง key", + "Show only selected models": "แสดงเฉพาะโมเดลที่เลือก", + "Showing": "กำลังแสดง", + "Shutdown": "ปิดระบบ", + "Sign in with OIDC": "เข้าสู่ระบบด้วย OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "แชท interface ง่ายๆ สำหรับโต้ตอบกับ AI models จาก connected providers เลือกโมเดลแล้วเริ่มแชท!", + "Single": "เดี่ยว", + "Single API endpoint for all major AI providers. Simplify your integration.": "API endpoint เดียวสำหรับ AI providers ทั้งหมด ทำให้ integration ของคุณง่ายขึ้น", + "Some models are not responding": "โมเดลบางตัวไม่ตอบสนอง", + "Sort Codex quotas by remaining": "เรียง Codex quotas ตามจำนวนที่เหลือ", + "Sort accounts by earliest quota reset time": "เรียงบัญชีตามเวลา quota reset เร็วที่สุด", + "Source Body": "Source Body", + "Sourcegraph Amp coding assistant CLI": "Sourcegraph Amp coding assistant CLI", + "Special reasoning/thinking tokens (fallback to output rate)": "Special reasoning/thinking tokens (fallback ไปยัง output rate)", + "Speech To Text": "Speech To Text", + "Speech-to-Text": "Speech-to-Text", + "Standard prompt tokens": "Standard prompt tokens", + "Start DNS": "เริ่ม DNS", + "Start Date": "วันที่เริ่มต้น", + "Start Free": "เริ่มฟรี", + "Start Headroom": "เริ่ม Headroom", + "Start Headroom separately at the configured URL, then recheck.": "เริ่ม Headroom แยกต่างหากที่ URL ที่กำหนด แล้วตรวจสอบอีกครั้ง", + "Start MITM": "เริ่ม MITM", + "Start Server": "เริ่มเซิร์ฟเวอร์", + "Start Tunnel": "เริ่ม Tunnel", + "Start a conversation": "เริ่มการสนทนา", + "Starting 9Router...": "กำลังเริ่ม 9Router...", + "Status": "สถานะ", + "Status:": "สถานะ:", + "Step 1: Open this URL in your browser": "ขั้นตอนที่ 1: เปิด URL นี้ในเบราว์เซอร์ของคุณ", + "Step 2: Paste the callback URL here": "ขั้นตอนที่ 2: วาง callback URL ที่นี่", + "Sticky Limit": "Sticky Limit", + "Sticky:": "Sticky:", + "Stop": "หยุด", + "Stop DNS": "หยุด DNS", + "Stop Headroom": "หยุด Headroom", + "Stop MITM": "หยุด MITM", + "Stop Server": "หยุดเซิร์ฟเวอร์", + "Stopped": "หยุดแล้ว", + "Strict Proxy": "Strict Proxy", + "Style": "สไตล์", + "Subagent Model": "Subagent Model", + "Sudo Password Required": "ต้องใช้ Sudo Password", + "Sudo password is required": "ต้องใช้ Sudo password", + "Suggested free models (≥200k context):": "โมเดลฟรีที่แนะนำ (≥200k context):", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "ตัวอย่าง shorthand ที่แนะนำ: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929", + "Support up to 20 active apps & 50 custom domains": "รองรับสูงสุด 20 active apps & 50 custom domains", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "Supported formats: protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "ซิงค์การตั้งค่าผ่านอุปกรณ์ต่างๆ ด้วย cloud storage ทางเลือก", + "System": "ระบบ", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel จะหยุด การเข้าถึงจากที่ไกลผ่าน Tailscale URL จะหยุดทำงาน", + "Tailscale installed": "ติดตั้ง Tailscale แล้ว", + "Tailscale is not installed. Install it to enable Funnel.": "ไม่ได้ติดตั้ง Tailscale ติดตั้งเพื่อเปิดใช้งาน Funnel", + "Target Request": "Target Request", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com", + "Temperature": "Temperature", + "Terminal": "Terminal", + "Terms of Service": "ข้อกำหนดการให้บริการ", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Terse-style system prompt → ลด output tokens ประมาณ 65% (สูงสุด 87%)", + "Test": "ทดสอบ", + "Test Again": "ทดสอบอีกครั้ง", + "Test All": "ทดสอบทั้งหมด", + "Test Example": "ตัวอย่างทดสอบ", + "Test Results": "ผลการทดสอบ", + "Test all API Key connections": "ทดสอบ API Key connections ทั้งหมด", + "Test all Compatible connections": "ทดสอบ Compatible connections ทั้งหมด", + "Test all Free connections": "ทดสอบ Free connections ทั้งหมด", + "Test all Free provider connections": "ทดสอบ Free provider connections ทั้งหมด", + "Test all OAuth connections": "ทดสอบ OAuth connections ทั้งหมด", + "Test connection": "ทดสอบการเชื่อมต่อ", + "Test model": "ทดสอบโมเดล", + "Test proxy": "ทดสอบ proxy", + "Test proxy URL": "ทดสอบ proxy URL", + "Testing...": "กำลังทดสอบ...", + "Text To Speech": "Text To Speech", + "Text To Speech combo": "Text To Speech combo", + "Text to Image": "Text to Image", + "Text to Image combo": "Text to Image combo", + "Text-to-Speech": "Text-to-Speech", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Text-to-image ผ่าน DALL-E, Imagen, FLUX, MiniMax, SDWebUI...", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลผ่าน tunnel URL จะหยุดทำงาน", + "The proxy server has been stopped.": "Proxy server ถูกหยุดแล้ว", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "Request ได้รับการตอบสนองจาก OpenAI, Anthropic, Gemini หรือผู้ให้บริการอื่นทันที", + "The tunnel will be disconnected. Remote access will stop working.": "Tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลจะหยุดทำงาน", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "Endpoint เดียวสำหรับ AI generation เชื่อมต่อ route และจัดการ AI providers ของคุณอย่างง่ายดาย", + "The unified interface for modern AI infrastructure": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่ ปลอดภัย ตรวจสอบได้ และขยายขนาดได้", + "Theme": "ธีม", + "Thinking": "Thinking", + "Thinking Process": "Thinking Process", + "This is the only time you will see this key. Store it securely.": "นี่เป็นครั้งเดียวที่คุณจะเห็น key นี้ กรุณาจัดเก็บอย่างปลอดภัย", + "This provider is ready to use.": "Provider นี้พร้อมใช้งาน", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "Provider นี้พร้อมใช้งาน ทางเลือกสามารถ route requests ผ่าน proxy pool เพื่อหลีกเลี่ยง IP-based limits", + "This value is write-only after saving.": "ค่านี้จะเขียนได้เฉพาะหลังจากบันทึกแล้ว", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "To get a fresh API key, paste your browser cookie from": "เพื่อรับ API key ใหม่ วาง browser cookie ของคุณจาก", + "Today": "วันนี้", + "Toggle DNS to redirect": "เปิด/ปิด DNS เพื่อ redirect", + "Toggle auto-ping": "เปิด/ปิด auto-ping", + "Token Saver": "Token Saver", + "Token Types:": "Token Types:", + "Token auto-detected from Kiro IDE successfully!": "ตรวจจับ Token จาก Kiro IDE สำเร็จ!", + "Token is used once for deployment and not stored.": "Token ใช้ครั้งเดียวสำหรับ deployment ไม่ได้จัดเก็บ", + "Token is used once for deployment, not stored. Found in Organization Settings.": "Token ใช้ครั้งเดียวสำหรับ deployment ไม่ได้จัดเก็บ พบใน Organization Settings", + "Token will be auto-filled...": "Token จะถูกเติมอัตโนมัติ...", + "Tokens": "Tokens", + "Tokens auto-detected from Cursor IDE successfully!": "ตรวจจับ Tokens จาก Cursor IDE สำเร็จ!", + "Tokens used to create cache entries (fallback to input rate)": "Tokens ที่ใช้สร้าง cache entries (fallback ไปยัง input rate)", + "Tomorrow": "พรุ่งนี้", + "Tool not found or disabled.": "ไม่พบเครื่องมือหรือถูกปิดใช้งาน", + "Tools": "เครื่องมือ", + "Tools:": "เครื่องมือ:", + "Total Cost": "Total Cost", + "Total Input Tokens": "Input Tokens ทั้งหมด", + "Total Models": "โมเดลทั้งหมด", + "Total Requests": "Requests ทั้งหมด", + "Total Tokens": "Tokens ทั้งหมด", + "Total:": "ทั้งหมด:", + "Track and manage your API quota limits": "ติดตามและจัดการ API quota limits ของคุณ", + "Track token usage, costs, and performance across all providers.": "ติดตาม token usage, costs และ performance ของ providers ทั้งหมด", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcribe audio ผ่าน OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI...", + "Transferring data...": "กำลังถ่ายโอนข้อมูล...", + "Translator": "Translator", + "Translator Debug": "Translator Debug", + "Tried in order (top-down) or rotated when round-robin is on.": "ลองตามลำดับ (บนลงล่าง) หรือหมุนเวียนเมื่อ round-robin เปิดอยู่", + "Trust Cert": "Trust Cert", + "Trusted": "เชื่อถือแล้ว", + "Try Again": "ลองอีกครั้ง", + "Tunnel": "Tunnel", + "Tunnel connected!": "Tunnel เชื่อมต่อแล้ว!", + "Tunnel disabled": "ปิด Tunnel แล้ว", + "Turn off Empty": "ปิด Empty", + "Turn on Available": "เปิด Available", + "Turn request detail recording on/off globally": "เปิด/ปิดการบันทึก request details ทั่วโลก", + "Twitter": "Twitter", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / text / HTML ผ่าน Firecrawl, Jina, Tavily, Exa", + "Unavailable": "ไม่พร้อมใช้งาน", + "Under": "ภายใต้", + "Unified Endpoint": "Unified Endpoint", + "Unknown": "ไม่ทราบ", + "Unselect all": "ยกเลิกเลือกทั้งหมด", + "Update": "อัปเดต", + "Update 9Router": "อัปเดต 9Router", + "Update Password": "อัปเดตรหัสผ่าน", + "Update now": "อัปเดตตอนนี้", + "Upstream Auth Error": "Upstream Auth Error", + "Upstream Unavailable": "Upstream ไม่พร้อมใช้งาน", + "Usage": "การใช้งาน", + "Usage & Analytics": "การใช้งานและ Analytics", + "Usage / Limit": "การใช้งาน / ขีดจำกัด", + "Usage Logs": "Usage Logs", + "Usage Tracking": "Usage Tracking", + "Usage by API Key": "การใช้งานตาม API Key", + "Usage by Account": "การใช้งานตามบัญชี", + "Usage by Endpoint": "การใช้งานตาม Endpoint", + "Usage by Model": "การใช้งานตามโมเดล", + "Usage:": "การใช้งาน:", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "ใช้ 9Router model aliases เพื่อรักษา Amp shorthand mappings ให้คงที่ตลอดการอัปเดต providers", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ใช้ Antigravity IDE & GitHub Copilot → กับ providers/models ใดก็ได้จาก 9Router", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "ใช้ Authentik หรือ OIDC providers ใดก็ได้เพื่อเข้าสู่ระบบ dashboard", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "ใช้ Authentik หรือ OIDC providers ใดก็ได้เพื่อเข้าสู่ระบบ dashboard คุณสามารถเปิดใช้งานเฉพาะรหัสผ่าน เฉพาะ OIDC หรือทั้งคู่สำหรับ dashboard; model API access ยังคงใช้ API keys", + "Use a GitLab OAuth application": "ใช้ GitLab OAuth application", + "Use a GitLab PAT with api scope": "ใช้ GitLab PAT ที่มี api scope", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "ใช้ xAI API key โดยตรงจาก console.x.ai นี้แยกจาก Grok Build OAuth", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "ใช้ local proxy สำหรับ Start/Stop หรือ external Docker sidecar เช่น http://headroom:8787", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "ใช้ Kiro/CodeWhisperer API key ที่มีอายุการใช้งานยาวนาน (headless auth)", + "Use in Cursor/Cline": "ใช้ใน Cursor/Cline", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "ใช้ปุ่มด้านบนเพื่อเพิ่ม OpenAI หรือ Anthropic compatible endpoints", + "Use your API from any network": "ใช้ API ของคุณจากเครือข่ายใดก็ได้", + "Valid": "ถูกต้อง", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vectors สำหรับ RAG / semantic search ผ่าน OpenAI, Gemini, Mistral...", + "Vercel API Token": "Vercel API Token", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel ให้บริการ millions of apps — providers ไม่สามารถบล็อก Vercel IPs โดยไม่กระทบ traffic ที่ถูกกฎหมาย", + "Verification URL": "Verification URL", + "Video": "วิดีโอ", + "View Codex reset credit expiry": "ดู Codex reset credit expiry", + "View Full Details": "ดูรายละเอียดทั้งหมด", + "View on GitHub": "ดูบน GitHub", + "Visit the URL below and enter the code:": "เยี่ยมชม URL ด้านล่างแล้วป้อนรหัส:", + "Visit the login URL below and authorize:": "เยี่ยมชม login URL ด้านล่างแล้วอนุมัติ:", + "Voice": "เสียง", + "Voice ID": "Voice ID", + "Voyage AI": "Voyage AI", + "Waiting for Authorization": "รอการอนุมัติ", + "Waiting for authorization...": "รอการอนุมัติ...", + "Warning": "คำเตือน", + "Web Fetch": "Web Fetch", + "Web Fetch & Search": "Web Fetch & Search", + "Web Search": "Web Search", + "Web Search & Fetch (Exa)": "Web Search & Fetch (Exa)", + "Welcome": "ยินดีต้อนรับ", + "What is Cloudflare Relay?": "Cloudflare Relay คืออะไร?", + "What is Deno Relay?": "Deno Relay คืออะไร?", + "What is Vercel Relay?": "Vercel Relay คืออะไร?", + "When": "เมื่อ", + "When ON, dashboard requires password. When OFF, access without login.": "เมื่อเปิด dashboard ต้องใช้รหัสผ่าน เมื่อปิด เข้าถึงได้โดยไม่ต้องเข้าสู่ระบบ", + "Windows:": "Windows:", + "Windows: Run 9Router terminal as Administrator": "Windows: รัน 9Router terminal ในฐานะผู้ดูแลระบบ", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: รัน terminal (9Router) ในฐานะผู้ดูแลระบบเพื่อเปิดใช้งาน MITM", + "Worker Name": "Worker Name", + "Works on any device": "ใช้งานได้บนทุกอุปกรณ์", + "Writes to": "เขียนไปที่", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "คุณสามารถแทนที่ default pricing สำหรับโมเดลเฉพาะได้ รีเซ็ตเป็นค่าเริ่มต้นเมื่อใดก็ได้เพื่อกลับไปใช้อัตราปกติ", + "Your": "ของคุณ", + "Your Account Name": "ชื่อบัญชีของคุณ", + "Your Code": "โค้ดของคุณ", + "Your Kiro account via": "Kiro account ของคุณผ่าน", + "Your OAuth application client ID": "OAuth application client ID ของคุณ", + "Your organization's AWS IAM Identity Center URL": "AWS IAM Identity Center URL ขององค์กรคุณ", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "Request ของคุณเริ่มจากเครื่องมือที่คุณชื่นชอบหรือ unified SDK ของเรา เพียงเปลี่ยน base URL", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "Request ของคุณเริ่มจากเครื่องมือที่คุณชื่นชอบ — Cursor, Claude, Copilot หรือ OpenAI-compatible SDK ใดก็ได้", + "account has been connected.": "บัญชีเชื่อมต่อแล้ว", + "active": "ใช้งานอยู่", + "add OpenAI/Anthropic compatible endpoints": "เพิ่ม OpenAI/Anthropic compatible endpoints", + "added)": "เพิ่มแล้ว)", + "again after install.": "อีกครั้งหลังการติดตั้ง", + "and click": "แล้วคลิก", + "apiKey": "apiKey", + "below.": "ด้านล่าง", + "bound": "เชื่อมต่อแล้ว", + "chars)": "ตัวอักษร)", + "cloudflare relay": "cloudflare relay", + "connection": "การเชื่อมต่อ", + "connections": "การเชื่อมต่อ", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "มืด", + "disabled": "ปิดใช้งานแล้ว", + "dollars per million tokens": "ดอลลาร์ต่อล้าน tokens", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "เช่น CwhRBWXzGAHq8TQ4Fs17", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "เช่น เสียงอบอุ่นนุ่มนวล พูดช้าๆ ด้วยสำเนียงอังกฤษ", + "e.g. claude-opus-4-5": "เช่น claude-opus-4-5", + "e.g. my-model-id": "เช่น my-model-id", + "e.g. tts-1-hd": "เช่น tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "เช่น voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "เช่น Production API, Dev Environment", + "every request bills all panel models + the judge": "ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge", + "export": "ส่งออก", + "failed": "ล้มเหลว", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → ลด input tokens 60-90%", + "h ago": "ชั่วโมงที่แล้ว", + "has been connected.": "เชื่อมต่อแล้ว", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "iFlow Cookie Authentication", + "import": "นำเข้า", + "inactive": "ไม่ใช้งาน", + "jcode - Manual Configuration": "jcode - กำหนดค่าด้วยตนเอง", + "jcode CLI not detected locally": "ไม่พบ jcode CLI บนเครื่อง", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode เป็น coding agent ที่สร้างด้วย Rust พร้อม semantic memory, multi-agent swarms และ extreme performance (27.8 MB RAM, 14ms boot)", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "สว่าง", + "m ago": "นาทีที่แล้ว", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "macOS/Linux:": "macOS/Linux:", + "more": "เพิ่มเติม", + "more providers": "providers เพิ่มเติม", + "ms / Total": "ms / ทั้งหมด", + "name|apiKey": "name|apiKey", + "no_proxy:": "ไม่มี proxy:", + "not detected locally": "ไม่พบบนเครื่อง", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "open http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "optional context เพื่อเพิ่มความแม่นยำ", + "or VS Code extension marketplace.": "หรือ VS Code extension marketplace", + "or just": "หรือเพียง", + "passed": "ผ่าน", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด: ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge (N+1 calls)", + "records, batches every": "records, ทุก batch", + "requests, max": "requests, สูงสุด", + "rotates models across requests to spread load": "หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "s)": "วินาที)", + "s...": "วินาที...", + "seconds...": "วินาที...", + "sends image/PDF/audio requests to a model that supports them first": "ส่ง image/PDF/audio requests ไปยังโมเดลที่รองรับก่อน", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router (ค่าเริ่มต้น)", + "system": "ตามระบบ", + "tested": "ทดสอบแล้ว", + "the database.": "ฐานข้อมูล", + "to apply changes": "เพื่อให้การเปลี่ยนแปลงมีผล", + "to verify.": "เพื่อตรวจสอบ", + "traffic through 9Router via MITM.": "traffic ผ่าน 9Router ผ่าน MITM", + "tries models in order (next on failure)": "ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "unknown": "ไม่ทราบ", + "v1.0 is now live": "v1.0 พร้อมใช้งานแล้ว", + "vercel relay": "vercel relay", + "yet.": "ในตอนนี้", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© 2025 9Router สงวนลิขสิทธิ์", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด: ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge (N+1 calls)", + "— rotates models across requests to spread load": "— หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "— sends image/PDF/audio requests to a model that supports them first": "— ส่ง image/PDF/audio requests ไปยังโมเดลที่รองรับก่อน", + "— tries models in order (next on failure)": "— ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ Target", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ เปิดใช้งาน DNS เพื่อแก้ไข model mappings", + "⚠️ Local plugins run as subprocess via": "⚠️ Local plugins ทำงานในฐานะ subprocess ผ่าน", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ดักจับ HTTPS traffic ของ IDE tools (Antigravity, GitHub Copilot, Kiro) ผ่าน local CA เพื่อ redirect requests ไปยัง providers ของคุณ อาจละเมิด ToS → บัญชีถูกแบน ใช้ด้วยความเสี่ยงของคุณเอง", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ ประกาศความเสี่ยง: Provider นี้ใช้ subscription/OAuth session ที่ไม่ได้รับอนุญาตอย่างเป็นทางการสำหรับการใช้งาน proxy/router บัญชีอาจถูกจำกัดหรือแบน ใช้ด้วยความเสี่ยงของคุณเอง", + "✓ Confirm Add": "✓ ยืนยันการเพิ่ม", + "📝 Configure providers in dashboard or use environment variables": "📝 กำหนดค่า providers ใน dashboard หรือใช้ environment variables", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 ต้องใช้ OAuth เพิ่มตอนนี้แล้ว authenticate หลัง Apply; รายการเครื่องมือจะถูกค้นพบหลังการเชื่อมต่อครั้งแรก" } diff --git a/public/i18n/literals/tl.json b/public/i18n/literals/tl.json index 51af4e24..7510740a 100644 --- a/public/i18n/literals/tl.json +++ b/public/i18n/literals/tl.json @@ -12,6 +12,7 @@ "Logout": "Maglog out", "Login": "Magsimula ng sesyon", "Providers": "Mga Provider", + "Style": "Estilo", "Usage": "Mga Istatistika ng Paggamit", "API Key": "Susi ng API", "Connected": "Konektado", @@ -176,6 +177,7 @@ "How it works:": "Paano ito gumagana:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE request → DNS redirect sa localhost:443 → MITM proxy intercepts → 9Router → response sa Antigravity/Copilot", "No API keys — create one in Keys page": "Walang API keys — lumikha ng isa sa Keys page", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "hal.: mainit at malumanay na boses, dahan-dahang nagsasalita nang may British accent", "sk_9router (default)": "sk_9router (default)", "Server started": "Ang server ay nagsimula", "Failed to start server": "Nabigo na magsimula ang server", diff --git a/public/i18n/literals/tr.json b/public/i18n/literals/tr.json index ac4042aa..bd7ec4f4 100644 --- a/public/i18n/literals/tr.json +++ b/public/i18n/literals/tr.json @@ -12,6 +12,7 @@ "Logout": "Çıkış Yap", "Login": "Giriş Yap", "Providers": "Sağlayıcılar", + "Style": "Stil", "Usage": "Kullanım İstatistikleri", "API Key": "API Anahtarı", "Connected": "Bağlı", @@ -176,6 +177,7 @@ "How it works:": "Nasıl çalışır:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE isteği → DNS'i localhost:443'e yönlendir → MITM proxy yakalar → 9Router → Antigravity/Copilot'a yanıt", "No API keys — create one in Keys page": "API anahtarı yok — Keys sayfasında bir tane oluşturun", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "örn.: sıcak ve yumuşak bir ses, İngiliz aksanıyla yavaş konuşuyor", "sk_9router (default)": "sk_9router (varsayılan)", "Server started": "Sunucu başlatıldı", "Failed to start server": "Sunucu başlatılamadı", diff --git a/public/i18n/literals/uk.json b/public/i18n/literals/uk.json index e238ad2b..378476ef 100644 --- a/public/i18n/literals/uk.json +++ b/public/i18n/literals/uk.json @@ -12,6 +12,7 @@ "Logout": "Вийти", "Login": "Увійти", "Providers": "Постачальники", + "Style": "Стиль", "Usage": "Статистика використання", "API Key": "Ключ API", "Connected": "Підключено", @@ -176,6 +177,7 @@ "How it works:": "Як це працює:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запит Antigravity/Copilot IDE → Перенаправлення DNS на localhost:443 → MITM проксі перехопити → 9Router → відповідь на Antigravity/Copilot", "No API keys — create one in Keys page": "Немає ключів API — створіть один на сторінці ключів", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "напр.: теплий м'який голос, що повільно говорить з британським акцентом", "sk_9router (default)": "sk_9router (за замовчуванням)", "Server started": "Сервер запущено", "Failed to start server": "Не вдалося запустити сервер", diff --git a/public/i18n/literals/ur.json b/public/i18n/literals/ur.json index e5921755..e0aa8cf7 100644 --- a/public/i18n/literals/ur.json +++ b/public/i18n/literals/ur.json @@ -12,6 +12,7 @@ "Logout": "لاگ آؤٹ", "Login": "لاگ ان", "Providers": "فراہم کنندگان", + "Style": "انداز", "Usage": "استعمال کے اعدادوشمار", "API Key": "API کلید", "Connected": "منسلک", @@ -176,6 +177,7 @@ "How it works:": "یہ کیسے کام کرتا ہے:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE درخواست → DNS کو localhost:443 کی طرف ری ڈائریکٹ کریں → MITM پروکسی روکے → 9Router → Antigravity/Copilot کو جواب", "No API keys — create one in Keys page": "کوئی API کلید نہیں — Keys صفحہ میں ایک بنائیں", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "مثلاً: گرم، نرم آواز، برطانوی لہجے کے ساتھ آہستہ بولتی ہوئی", "sk_9router (default)": "sk_9router (ڈیفالٹ)", "Server started": "سرور شروع ہوگیا", "Failed to start server": "سرور شروع کرنے میں ناکام", diff --git a/public/i18n/literals/vi.json b/public/i18n/literals/vi.json index 5358d068..51100780 100644 --- a/public/i18n/literals/vi.json +++ b/public/i18n/literals/vi.json @@ -12,6 +12,7 @@ "Logout": "Đăng xuất", "Login": "Đăng nhập", "Providers": "Nhà cung cấp", + "Style": "Phong cách", "Usage": "Thống kê", "API Key": "Khóa API", "Connected": " Đã kết nối", @@ -176,6 +177,7 @@ "How it works:": "Cách hoạt động:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Yêu cầu Antigravity/Copilot IDE → Chuyển hướng DNS đến localhost:443 → MITM proxy chặn → 9Router → phản hồi đến Antigravity/Copilot", "No API keys — create one in Keys page": "Không có khóa API — tạo một khóa trong trang Keys", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "vd.: giọng ấm áp, nhẹ nhàng, nói chậm với giọng Anh-Anh", "sk_9router (default)": "sk_9router (mặc định)", "Server started": "Đã khởi động máy chủ", "Failed to start server": "Không thể khởi động máy chủ", diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index 41d29c5e..da045742 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -1,46 +1,36 @@ { - "-compatible models manually or import them from the /models endpoint.": "- 手动兼容模型或从 /models 端点导入它们。", - ". Click \"Apply\" to auto-configure.": "。单击“应用”进行自动配置。", "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/100 万 Token)。示例:输入费率 2.50 表示每 1,000,000 个输入 Token 需 2.50 美元。", "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/100 万 Token)。示例:输入费率 2.50 表示每 1,000,000 个输入 Token 需 2.50 美元。", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(通过推理测试)", + "+ Browse": "+ 浏览", + "+ Combo": "+ 组合", + "+ Custom": "+ 自定义", + "+ Save current as...": "+ 另存为...", + "-compatible models manually or import them from the /models endpoint.": "- 手动兼容模型或从 /models 端点导入它们。", + ". Click \"Apply\" to auto-configure.": "。单击“应用”进行自动配置。", + "1. CLI & SDKs": "1. CLI 和 SDK", "1. Client Request (Input)": "1. 客户端请求(输入)", "1. Generates SSL cert & adds to system keychain": "1. 生成 SSL 证书并添加到系统钥匙串", + "2. 9Router Hub": "2. 9Router 枢纽", "2. Provider Request (Translated)": "2. 提供商请求(已​​翻译)", "2. Redirects": "2. 重定向", "24h": "24小时", + "3. AI Providers": "3. AI 提供商", "3. Maps Antigravity models to any provider via 9Router": "3. 通过 9Router 将Antigravity模型映射到任何提供商", "3. Provider Response (Raw)": "3. 提供商响应(原始)", + "30D": "30 天", "4. Client Response (Final)": "4. 客户端响应(最终)", - "About": "关于", - "Access Anywhere": "随处访问", - "Access Token": "访问令牌", - "Access token will be auto-filled...": "访问令牌将自动填充...", - "Account": "账号", - "account has been connected.": "账号已连接。", - "Action": "操作", - "Active": "活跃", - "Add": "添加", - "Add a connection to enable importing models.": "添加连接以启用导入模型。", - "Add Anthropic Compatible": "添加Anthropic兼容", - "Add Connection": "添加连接", - "Add connection using browser cookie": "使用浏览器 cookie 添加连接", - "Add Custom Model": "添加自定义模型", - "Add model": "添加模型", - "Add Model": "添加模型", - "Add Model to Combo": "将模型添加到组合", - "Add New Provider": "添加新提供商", - "Add OpenAI Compatible": "添加 OpenAI 兼容", - "Add your first connection to get started": "添加您的第一个连接以开始使用", - "added)": "已添加)", - "After authorization, copy the full URL from your browser address bar.": "授权后,从浏览器地址栏中复制完整的 URL。", - "After authorization, copy the full URL from your browser.": "授权后,从浏览器复制完整的 URL。", - "After installation, run": "安装后,运行", - "After login, you'll need to copy the callback URL from your browser and paste it back here.": "登录后,您需要从浏览器复制回调 URL 并将其粘贴回此处。", - "All models are responding normally.": "所有模型均响应正常。", - "All Providers": "所有提供商", - "All rates are in": "所有费率均在", - "An error occurred": "发生错误", - "Anthropic Compatible (Prod)": "Anthropic 兼容(生产)", + "60D": "60 天", + "7D": "7 天", + "9Router (Entry)": "9Router(入口)", + "9Router Base URL": "9Router 基础 URL", + ": Account | Workers Scripts | Edit": ":账号 | Workers 脚本 | 编辑", + ": Include | Account |": ":包含 | 账号 |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "AI 端点代理,附带 Web 仪表盘 — CLIProxyAPI 的 JavaScript 移植版。与 Claude Code、OpenAI Codex、Cline、RooCode 和其他 CLI 工具无缝协作。", "API Endpoint": "API端点", "API Key": "API密钥", "API Key (for Check)": "API 密钥(用于检查)", @@ -50,286 +40,824 @@ "API Key Providers": "API 密钥提供商", "API Keys": "API 密钥", "API Reference": "API参考", + "API Token": "API 令牌", + "API Tokens": "API 令牌", "API Type": "API类型", - "Apply": "应用", - "Are you sure you want to disable the tunnel?": "您确定要禁用隧道吗?", - "Authenticate": "认证", - "Authentication Method": "认证方式", - "Authentication Successful!": "认证成功!", - "Authorization Successful!": "授权成功!", - "Auto Refresh (3s)": "自动刷新(3秒)", - "Auto-detecting token...": "自动检测令牌...", - "Auto-detecting tokens...": "自动检测令牌...", - "Auto:": "自动:", - "Available": "可用", + "API Version": "API 版本", + "API endpoint configuration": "API 端点配置", "AWS Builder ID": "AWS 构建器 ID", "AWS IAM Identity Center": "AWS IAM 身份中心", "AWS Region": "AWS 区域", + "AWS region for the key (default: us-east-1)": "密钥的 AWS 区域(默认:us-east-1)", "AWS region for your Identity Center (default: us-east-1)": "您的身份中心的 AWS 区域(默认值:us-east-1)", + "About": "关于", + "Access Anywhere": "随处访问", + "Access Token": "访问令牌", + "Access token will be auto-filled...": "访问令牌将自动填充...", + "Access your terminal, desktop & files from anywhere": "从任何地方访问您的终端、桌面和文件", + "Account": "账号", + "Account ID": "账号 ID", + "Account Resources": "账号资源", + "Accounts per page": "每页账号数", + "Action": "操作", + "Activate": "激活", + "Active": "活跃", + "Active All": "全部激活", + "Active:": "活跃:", + "Add": "添加", + "Add API Key": "添加 API 密钥", + "Add Anthropic Compatible": "添加Anthropic兼容", + "Add Connection": "添加连接", + "Add Custom Embedding": "添加自定义嵌入", + "Add Custom MCP": "添加自定义 MCP", + "Add Custom Model": "添加自定义模型", + "Add Model": "添加模型", + "Add Model Config": "添加模型配置", + "Add Model for GitHub Copilot": "为 GitHub Copilot 添加模型", + "Add Model for OpenCode": "为 OpenCode 添加模型", + "Add Model to Combo": "将模型添加到组合", + "Add New Provider": "添加新提供商", + "Add OpenAI Compatible": "添加 OpenAI 兼容", + "Add Provider": "添加提供商", + "Add Proxy Pool": "添加代理池", + "Add Shorthands": "添加简写", + "Add a connection to enable importing models.": "添加连接以启用导入模型。", + "Add connection using browser cookie": "使用浏览器 cookie 添加连接", + "Add model": "添加模型", + "Add server": "添加服务器", + "Add the following configuration to your models array:": "将以下配置添加到您的 models 数组中:", + "Add your first connection to get started": "添加您的第一个连接以开始使用", + "Administrator required": "需要管理员权限", + "Administrator required — restart 9Router as Administrator to use MITM": "需要管理员权限 — 以管理员身份重新启动 9Router 以使用 MITM", + "After authorization, copy the full URL from your browser address bar.": "授权后,从浏览器地址栏中复制完整的 URL。", + "After authorization, copy the full URL from your browser.": "授权后,从浏览器复制完整的 URL。", + "After installation, run": "安装后,运行", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "登录后,您需要从浏览器复制回调 URL 并将其粘贴回此处。", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "阿里巴巴 Qwen Code CLI — 通过 9Router 支持 OpenAI、Anthropic 和 Gemini 提供商", + "All": "全部", + "All AI Providers": "所有 AI 提供商", + "All Providers": "所有提供商", + "All models are responding normally.": "所有模型均响应正常。", + "All providers": "所有提供商", + "All rates are in": "所有费率均在", + "All selected currently unbound": "所有选中项当前未绑定", + "Allow dashboard access via tunnel": "允许通过隧道访问仪表盘", + "Allow either password or OIDC.": "允许密码或 OIDC 登录。", + "An error occurred": "发生错误", + "An error occurred. Please try again.": "发生错误,请重试。", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "Anthropic 兼容(生产)", + "Anthropic Compatible Details": "Anthropic 兼容详情", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE 请求 → DNS 重定向到 localhost:443 → MITM 代理拦截 → 9Router → 响应返回 Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "9Router 中可用的任何模型都可以使用——不仅仅是 Qwen 模型。从 Qwen、Claude、Gemini、GPT 等中选择。", + "App Name": "应用名称", + "Apply": "应用", + "Apply Proxy": "应用代理", + "Applying...": "应用中...", + "Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?", + "Are you sure you want to disable the tunnel?": "您确定要禁用隧道吗?", + "Attempting to reconnect...": "正在尝试重新连接...", + "Audio File": "音频文件", + "Auth Mode": "认证模式", + "Authenticate": "认证", + "Authentication Method": "认证方式", + "Authentication Successful": "认证成功", + "Authentication Successful!": "认证成功!", + "Authless": "无需认证", + "Authorization Successful!": "授权成功!", + "Authorize": "授权", + "Auto (by priority)": "自动(按优先级)", + "Auto Refresh (3s)": "自动刷新(3秒)", + "Auto-detect": "自动检测", + "Auto-detecting token...": "自动检测令牌...", + "Auto-detecting tokens...": "自动检测令牌...", + "Auto-ping": "自动 Ping", + "Auto-refresh": "自动刷新", + "Auto:": "自动:", + "Automatically switch between providers when limits are hit.": "当达到限额时自动切换提供商。", + "Available": "可用", + "Available Models": "可用模型", + "Azure Endpoint": "Azure 端点", + "Azure OpenAI Configuration": "Azure OpenAI 配置", + "BXAuth=xxx; ...": "BXAuth=xxx;...", "Back": "返回", + "Back to CLI Tools": "返回 CLI 工具", "Back to Providers": "返回提供商", "Base URL": "基础 URL", + "Batch Import": "批量导入", + "Batch Import Proxies": "批量导入代理", "Batch Size": "批量大小", + "Beautiful web dashboard for managing providers and monitoring usage.": "精美的 Web 仪表盘,用于管理提供商和监控使用情况。", + "Best quality, but costs the most": "质量最佳但成本最高", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "引导模型编写最少代码:YAGNI、重用标准库、删除多于添加", + "Binary File": "二进制文件", "Blog": "博客", + "Both": "两者", + "Browse & edit files": "浏览和编辑文件", + "Browse MCP Marketplace": "浏览 MCP 市场", + "Browse source, README, and examples.": "浏览源代码、README 和示例。", + "Browser Control (Browser MCP)": "浏览器控制(Browser MCP)", + "Bulk Add": "批量添加", + "CLI Support": "CLI 支持", + "CLI Tools": "命令行工具", + "CLI on the host →": "主机上的 CLI →", + "CLIProxyAPI Auth JSON": "CLIProxyAPI 认证 JSON", "Cache Creation": "缓存创建", "Cache Creation:": "缓存创建:", "Cached": "缓存", + "Cached Tokens": "缓存 Token", + "Cached Tokens:": "缓存 Token:", "Cached input tokens (typically 50% of input rate)": "缓存输入 Token(通常为输入费率的 50%)", "Cached:": "缓存:", "Calls per account before switching": "切换前每个账号的调用次数", + "Calls per combo model before switching": "切换前每个组合模型的调用次数", "Cancel": "取消", + "Capacity auto-switch": "容量自动切换", "Cert": "证书", + "Change Log": "更新日志", "Changelog": "变更日志", - "chars)": "字符)", + "Chat": "对话", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "通过 OpenAI 或 Anthropic 格式进行聊天/代码生成,支持流式传输。", "Chat Completions": "聊天完成", + "Check": "检查", "Checking Claude CLI...": "检查 Claude CLI...", + "Checking Claude Cowork...": "正在检查 Claude Cowork...", + "Checking Cline...": "正在检查 Cline...", "Checking Codex CLI...": "正在检查 Codex CLI...", "Checking Copilot config...": "正在检查Copilot配置...", + "Checking DeepSeek TUI...": "正在检查 DeepSeek TUI...", "Checking Factory Droid CLI...": "检查 Factory Droid CLI...", + "Checking Hermes Agent...": "正在检查 Hermes Agent...", + "Checking Kilo Code...": "正在检查 Kilo Code...", "Checking Open Claw CLI...": "正在检查 Open Claw CLI...", "Checking OpenCode CLI...": "检查 OpenCode CLI...", + "Checking jcode CLI...": "正在检查 jcode CLI...", + "Checking...": "检查中...", + "Choose API Provider → Ollama": "选择 API 提供商 → Ollama", + "Choose how to authenticate with GitLab Duo:": "选择如何通过 GitLab Duo 认证:", "Choose your authentication method:": "选择您的身份验证方法:", "Claude": "Claude", "Claude CLI - Manual Configuration": "Claude CLI - 手动配置", + "Claude CLI not detected locally": "未在本地检测到 Claude CLI", "Claude CLI not installed": "Claude CLI 未安装", + "Claude Cowork - Manual Configuration": "Claude Cowork - 手动配置", + "Claude Desktop (Cowork mode) not detected": "未检测到 Claude Desktop(Cowork 模式)", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork(第三方推理)", "Clear": "清除", + "Clear (will use main model)": "清除(将使用主模型)", "Clear Filters": "清除过滤器", "Clear search": "清除搜索", + "Click": "点击", + "Click \"View All Model\" → \"Add Custom Model\"": "点击「查看所有模型」→「添加自定义模型」", + "Click a model to set/clear active": "点击模型以设置/取消活跃状态", + "Click to add, click again to remove. Changes are saved automatically.": "点击添加,再次点击删除。更改将自动保存。", "Click to edit": "点击编辑", + "Click to retry": "点击重试", + "Client ID": "客户端 ID", + "Client Request": "客户端请求", + "Client Response": "客户端响应", + "Client Secret": "客户端密钥", + "Cline - Manual Configuration": "Cline - 手动配置", + "Cline AI Coding Assistant": "Cline AI 编程助手", + "Cline not detected locally": "未在本地检测到 Cline", + "Close": "关闭", + "Close Proxy": "关闭代理", + "Close provider filter": "关闭提供商筛选", + "Close reset credit expiry modal": "关闭重置信用有效期弹窗", "Close test results": "关闭测试结果", + "Closing in": "即将关闭", + "Cloud Sync": "云端同步", + "Cloudflare Relay": "Cloudflare Relay", "Cloudflare Tunnel": "Cloudflare 隧道", + "Cloudflare Workers AI": "Cloudflare Workers AI", "Codex CLI - Manual Configuration": "Codex CLI - 手动配置", + "Codex CLI not detected locally": "未在本地检测到 Codex CLI", "Codex CLI not installed": "Codex CLI 未安装", + "Codex Reset Credit Expiry": "Codex 重置信用有效期", "Codex uses": "Codex 使用", "Combo Name": "组合名称", + "Combo Round Robin": "组合轮询", + "Combo Sticky Limit": "组合粘性限制", "Combos": "组合", "Coming soon...": "即将推出...", "Comma-separated hostnames/domains to bypass the proxy.": "以逗号分隔的主机名/域以绕过代理。", + "Comma-separated hosts/domains to bypass proxy": "以逗号分隔的主机名/域以绕过代理", "Company": "公司", "Complete the authorization in the popup window.": "在弹出的窗口中完成授权。", "Completion/response tokens": "补全/响应 Token", + "Compress LLM output": "压缩 LLM 输出", + "Compress context": "压缩上下文", + "Compress prompts via /v1/compress before routing to the model": "在路由到模型之前通过 /v1/compress 压缩提示", + "Compress tool output": "压缩工具输出", + "Compress tool output to reduce token usage.": "压缩工具输出以减少 Token 使用量。", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "配置路径:Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "配置路径:Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "配置", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "将 9router 配置为 OpenAI 兼容提供商,以通过 9router 的优化层路由所有 jcode 请求。", + "Configure CLI tools": "配置 CLI 工具", "Configure a new AI provider to use with your applications.": "配置新的 AI 提供程序以与您的应用程序一起使用。", "Configure pricing rates for cost tracking and calculations": "配置定价以进行成本跟踪和计算", + "Configure providers and API keys via web interface": "通过 Web 界面配置提供商和 API 密钥", + "Configured": "已配置", "Confirm": "确认", - "Confirm new password": "确认新密码", "Confirm New Password": "确认新密码", + "Confirm Password": "确认密码", + "Confirm new password": "确认新密码", "Connect": "连接", "Connect AI tools remotely": "远程连接AI工具", "Connect Cursor IDE": "连接CursorIDE", + "Connect GitLab Duo": "连接 GitLab Duo", "Connect Kiro": "连接Kiro", "Connect to providers with OAuth to track your API quota limits and usage.": "使用 OAuth 连接到提供商以跟踪您的 API 配额限制和使用情况。", + "Connect via OAuth or API keys. Securely manage credentials.": "通过 OAuth 或 API 密钥连接。安全管理凭据。", "Connect with OAuth2": "使用 OAuth2 连接", "Connect your account using OAuth2 authentication.": "使用 OAuth2 身份验证连接您的账号。", "Connected": "已连接", "Connected Successfully!": "连接成功!", + "Connected providers only": "仅已连接的提供商", + "Connecting...": "连接中...", + "Connection": "连接", + "Connection Details": "连接详情", "Connection Failed": "连接失败", "Connections": "连接", + "Console Log": "控制台日志", "Contact": "联系", "Content": "内容", "Continue": "继续", + "Continue AI Assistant": "Continue AI 助手", + "Continue to summary": "继续到摘要", "Continue with GitHub": "继续使用 GitHub", "Continue with Google": "使用 Google 继续", "Cookie": "Cookie", "Cookie Auth": "Cookie 验证", "Cookie String": "Cookie 字符串", "Cooldown": "冷却", + "Copied!": "已复制!", "Copy": "复制", - "Copy combo name": "复制组合名称", - "Copy model": "复制模型", - "Copy the entire cookie string (must include BXAuth)": "复制整个 cookie 字符串(必须包括 BXAuth)", + "Copy & Shutdown": "复制并关闭", "Copy This URL": "复制此 URL", + "Copy a link and paste to your AI to use 9Router — no install needed": "复制链接并粘贴到您的 AI 以使用 9Router — 无需安装", + "Copy combo name": "复制组合名称", + "Copy install command": "复制安装命令", + "Copy model": "复制模型", + "Copy the JSON below to your ~/.qwen/settings.json file.": "将以下 JSON 复制到您的 ~/.qwen/settings.json 文件中。", + "Copy the entire cookie string (must include BXAuth)": "复制整个 cookie 字符串(必须包括 BXAuth)", "Cost": "成本", "Cost Calculation:": "成本计算:", + "Costs": "成本", "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "成本根据 Token 用量和费率计算。每个请求的成本由以下公式决定:(input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", "Could not read Cursor database automatically.": "无法自动读取 Cursor 数据库。", "Create": "创建", "Create API Key": "创建 API 密钥", "Create Combo": "创建组合", + "Create Cowork Combo": "创建 Cowork 组合", "Create Key": "创建密钥", - "Create model combos with fallback support": "创建具有后备支持的模型组合", "Create Provider": "创建提供商", + "Create Token": "创建令牌", + "Create a": "创建一个", + "Create a proxy pool entry, then assign it to connections.": "创建代理池条目,然后分配到连接。", + "Create model combos with fallback support": "创建具有后备支持的模型组合", "Create your first API key to get started": "创建您的第一个 API 密钥以开始使用", "Created": "已创建", + "Creating...": "创建中...", "Current": "当前", "Current Password": "当前密码", "Current Pricing Overview": "当前定价概述", + "Current password": "当前密码", "Current: Keeps": "当前: 保留", + "Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。", + "Cursor AI Code Editor": "Cursor AI 代码编辑器", "Cursor IDE not detected. Please paste your tokens manually.": "未检测到Cursor IDE。请手动粘贴您的令牌。", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor 通过自己的服务器路由请求,因此不支持本地端点。请在设置中启用隧道或云端点。", + "Custom": "自定义", "Custom Pricing:": "定制定价:", + "Custom Providers (OpenAI/Anthropic Compatible)": "自定义提供商(OpenAI/Anthropic 兼容)", + "Custom Token": "自定义令牌", + "Custom accounts per page": "自定义每页账号数", + "Custom providers": "自定义提供商", + "Custom...": "自定义...", "Cycle through accounts to distribute load": "循环切换账号以分配负载", + "Cycle through providers in combos instead of always starting with first": "在组合中循环使用提供商,而不是总是从第一个开始", + "DNS off": "DNS 关闭", + "Dashboard": "仪表盘", + "Dashboard Password": "仪表盘密码", + "Dashboard:": "仪表盘:", + "Data Location:": "数据位置:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "数据从您的应用程序通过我们的智能路由层无缝流向最适合任务的提供商。", + "Data flows seamlessly through our intelligent routing system": "数据通过我们的智能路由系统无缝流转", + "Database Location": "数据库位置", "Database backup downloaded": "数据库备份已下载", "Database imported successfully": "数据库导入成功", - "Database Location": "数据库位置", "DateTime": "日期时间", + "Deactivate": "停用", + "Debug": "调试", "Debug translation flow between formats": "调试格式之间的翻译流程", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - 手动配置", + "DeepSeek TUI not detected locally": "未在本地检测到 DeepSeek TUI", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI 使用 ~/.deepseek/config.toml 进行配置。9Router 将提供商更新为 'openai' 模式,包含您的 base_url、api_key 和 model。", + "DeepSeek Terminal Coding Agent (Rust TUI)": "DeepSeek 终端编程代理(Rust TUI)", + "Default Model": "默认模型", + "Default password is": "默认密码为", + "Default password is 123456": "默认密码为 123456", "Delete": "删除", + "Delete API Key": "删除 API 密钥", + "Delete connection": "删除连接", + "Delete saved endpoint": "删除已保存的端点", + "Delete selected preset": "删除选中的预设", + "Delete this combo?": "删除此组合?", + "Delete this connection?": "删除此连接?", + "Deno Deploy API Token": "Deno Deploy API 令牌", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 运行在高性能全球边缘网络上", + "Deno Relay": "Deno Relay", + "Deploy": "部署", + "Deploy Cloudflare Relay": "部署 Cloudflare Relay", + "Deploy Deno Relay": "部署 Deno Relay", + "Deploy Relay": "部署中继", + "Deploy Vercel Relay": "部署 Vercel Relay", + "Deploy multiple relays for maximum IP diversity": "部署多个中继以获得最大 IP 多样性", + "Deploy multiple relays on different accounts for more IP diversity": "在不同账号上部署多个中继以获得更多 IP 多样性", + "Deploying... (may take ~1 min)": "部署中...(可能需要约 1 分钟)", + "Deployment Name": "部署名称", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "将 Cloudflare Worker 部署为代理中继。所有 AI 提供商请求将通过 Cloudflare 的全球边缘网络转发。", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "将中继 Worker 部署到 Deno Deploy 的全球边缘网络。所有 AI 提供商请求通过 Deno 的边缘转发,隐藏您的真实 IP。", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "将边缘中继函数部署到 Vercel,通过 Vercel 的网络代理请求。", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "将边缘中继函数部署到 Vercel。所有 AI 提供商请求将通过 Vercel 的边缘网络转发,隐藏您的真实 IP。", + "Desktop": "桌面", "Detail": "详情", + "Details": "详情", + "Dimensions": "维度", + "Disable": "禁用", + "Disable All": "全部禁用", + "Disable Tailscale": "禁用 Tailscale", "Disable Tunnel": "禁用隧道", + "Disable connections with depleted quota on the current page": "禁用当前页面上配额已耗尽的连接", + "Disable provider": "禁用提供商", + "Disable this model": "禁用此模型", "Disabled": "已禁用", + "Disabling...": "关闭中...", + "Disconnected from server": "与服务器断开连接", + "Dismiss notification": "关闭通知", "Display Name": "显示名称", - "DNS off": "DNS 关闭", + "Display language": "显示语言", + "Docs": "文档", "Documentation": "文档", - "dollars per million tokens": "美元 / 百万 Token", "Domain:": "域名:", + "Donate": "捐赠", "Done": "完成", + "Download": "下载", "Download Backup": "下载备份", - "e.g. claude-opus-4-5": "例如 claude-opus-4-5", - "e.g., Production API, Dev Environment": "例如,生产 API、开发环境", + "Drag to reorder": "拖拽以排序", + "Easy Setup": "简单设置", "Edit": "编辑", + "Edit Combo": "编辑组合", "Edit Connection": "编辑连接", "Edit Pricing": "编辑定价", + "Edit Proxy Pool": "编辑代理池", + "Edit connection": "编辑连接", + "Edit hosts file manually to add the following entries:": "手动编辑 hosts 文件以添加以下条目:", "Email": "邮箱", + "Embedding": "嵌入", + "Embeddings": "嵌入", + "Enable": "启用", "Enable DNS per tool below to activate interception": "启用下面每个工具的 DNS 以激活拦截", + "Enable DNS to edit model mappings": "启用 DNS 以编辑模型映射", "Enable Observability": "启用可观察性", - "Enable proxy for OAuth + provider outbound requests.": "为 OAuth + 提供商出站请求启用代理。", + "Enable OpenAI API": "启用 OpenAI API", "Enable Tunnel": "启用隧道", + "Enable connections that still have quota on the current page": "启用当前页面上仍有配额的连接", + "Enable provider": "启用提供商", + "Enable proxy for OAuth + provider outbound requests.": "为 OAuth + 提供商出站请求启用代理。", "Encrypted": "已加密", "End Date": "结束日期", "End-to-end TLS via Cloudflare": "通过 Cloudflare 的端到端 TLS", "Endpoint": "端点", "Endpoint & Key": "端点与密钥", + "Endpoint is exposed without an API key.": "端点未设置 API 密钥即对外暴露。", "Enter current password": "输入当前密码", + "Enter model id": "输入模型 ID", + "Enter model id (provider-specific)": "输入模型 ID(提供商特定)", "Enter new API key": "输入新的 API 密钥", "Enter new password": "输入新密码", + "Enter or pick API key": "输入或选择 API 密钥", + "Enter password": "输入密码", "Enter sudo password": "输入sudo密码", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "输入您的兼容端点所需的模型 ID。此模型将保存为连接的默认模型。", "Enter your API key": "输入您的 API 密钥", + "Enter your current password to": "输入当前密码以", + "Enter your password to access the dashboard": "输入密码以访问仪表盘", + "Error": "错误", "Est. Cost": "预估成本", "Estimated, not actual billing": "预估费用,非实际账单", + "Everything you need to manage your AI infrastructure efficiently.": "高效管理 AI 基础设施所需的一切。", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "为规模化而构建,在一个地方管理 AI 基础设施所需的一切。", + "Example": "示例", + "Experimental": "实验性", + "Expires At": "过期时间", + "Expiring first": "即将过期优先", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "「即将过期优先」当前会在当前页面内重新排序账号。跨页面排序仍遵循后端分页。", "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "将您本地的 9Router 暴露到互联网。无需端口转发,无需静态 IP。与您的团队共享端点 URL 或从任何地方在 Cursor、Cline 和其他 AI 工具中使用它。", "Factory Droid - Manual Configuration": "Factory Droid - 手动配置", + "Factory Droid AI Assistant": "Factory Droid AI 助手", + "Factory Droid CLI not detected locally": "未在本地检测到 Factory Droid CLI", "Factory Droid CLI not installed": "Factory Droid CLI 未安装", + "Fail request if proxy is unreachable instead of falling back to direct.": "当代理不可达时直接失败,而不是回退到直连。", + "Failed to apply settings": "应用设置失败", + "Failed to create combo": "创建组合失败", + "Failed to load changelog:": "加载更新日志失败:", + "Failed to load usage statistics.": "无法加载使用情况统计信息。", + "Failed to reset settings": "重置设置失败", + "Failed to set alias": "设置别名失败", + "Failed to update combo": "更新组合失败", + "Failed to update password": "更新密码失败", + "Failed to update proxy settings": "更新代理设置失败", + "Fallback": "回退", + "Fallback — tries models in order (next on failure)": "回退 — 按顺序尝试模型(失败时切换到下一个)", + "Fallback — try in order": "回退 — 按顺序尝试", + "Features": "功能特性", "Fetch Qoder Models": "获取 Qoder 模型", "Fetching...": "获取中...", - "Failed to load usage statistics.": "无法加载使用情况统计信息。", - "Features": "功能特性", + "Files": "文件", + "Filter accounts by status": "按状态筛选账号", + "Filter naming": "过滤命名", + "Filter naming requests": "过滤命名请求", + "Filter quota providers": "筛选配额提供商", + "Find MCPs →": "查找 MCP →", + "Find your Account ID in the right sidebar of": "在右侧边栏中找到您的账号 ID", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "在 dash.cloudflare.com 的右侧边栏中找到您的账号 ID", + "First Page": "首页", "Flush Interval (ms)": "刷新间隔(毫秒)", "For enterprise users with custom AWS IAM Identity Center.": "适用于具有自定义 AWS IAM Identity Center 的企业用户。", + "Forgot password? Open": "忘记密码?打开", + "Format": "格式化", + "Found on the right side of the Cloudflare dashboard overview page.": "位于 Cloudflare 仪表盘概览页面的右侧。", + "Free": "免费", + "Free & Free Tier Providers": "免费及免费额度提供商", "Free Providers": "免费提供商", + "Free Tier": "免费额度", + "Free Tier Providers": "免费额度提供商", + "Free tier: 100,000 requests per day": "免费套餐:每天 10 万次请求", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "免费套餐:100GB 带宽/月,50 万次边缘调用", + "Free tier: 1M requests & 100GiB outbound traffic per month": "免费套餐:每月 100 万次请求和 100GiB 出站流量", "Fresh API key obtained": "获得新的 API 密钥", + "Full shell access": "完整 Shell 访问", + "Fusion": "融合", + "Fusion — panel + judge": "融合 — 面板 + 裁判", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "融合 — 并行查询所有模型,然后由裁判综合出一个答案", + "Get 9Remote": "获取 9Remote", + "Get API Key": "获取 API 密钥", + "Get API Key →": "获取 API 密钥 →", + "Get Started": "开始使用", + "Get Started in 30 Seconds": "30 秒快速上手", + "Get started": "开始使用", + "Get started in seconds. Just install, open, and route.": "几秒钟即可上手。安装、打开、路由。", + "Get token →": "获取令牌 →", + "GitHub": "GitHub", "GitHub Account": "GitHub 账号", "GitHub Copilot - Manual Configuration": "GitHub Copilot - 手动配置", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE 与 MITM", + "GitLab Access Tokens": "GitLab 访问令牌", + "GitLab Applications": "GitLab 应用", + "GitLab Base URL": "GitLab 基础 URL", + "Go to": "前往", + "Go to Roo Settings panel": "前往 Roo 设置面板", "Google Account": "Google 账号", - "has been connected.": "已连接。", + "Google Antigravity IDE with MITM": "Google Antigravity IDE 与 MITM", + "Granted At": "授予时间", + "Group models under one name, then pick a strategy per combo:": "将模型归组到一个名称下,然后为每个组合选择策略:", + "Headroom proxy is reachable. You can enable the token saver.": "Headroom 代理可达。您可以启用 Token 节省器。", "Help Center": "帮助中心", - "How it works:": "工作原理:", + "Hermes Agent - Manual Configuration": "Hermes Agent - 手动配置", + "Hermes Agent not detected locally": "未在本地检测到 Hermes Agent", + "Hide": "隐藏", + "Hide key": "隐藏密钥", + "High performance global routing and IP masking via Cloudflare Workers": "通过 Cloudflare Workers 实现高性能全局路由和 IP 隐藏", + "High-performance Rust-based coding agent harness": "基于 Rust 的高性能编程代理框架", + "History": "历史记录", + "How 9Router Works": "9Router 工作原理", "How Pricing Works": "定价如何运作", + "How it Works": "工作原理", + "How it works:": "工作原理:", + "How to Install": "如何安装", + "How to generate API token:": "如何生成 API 令牌:", + "How to generate your API Token:": "如何生成您的 API 令牌:", "How to get cookie:": "如何获取cookie:", + "ID:": "ID:", "IDC Start URL": "IDC 起始 URL", - "iFlow AI": "iFlow AI", - "iFlow Cookie Authentication": "iFlow Cookie 身份验证", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "如果提供商不支持 /models 端点,请输入模型 ID 通过 chat/completions 进行验证。", + "Image Generation": "图像生成", + "Image to Text": "图像转文本", + "Import": "导入", "Import Backup": "导入备份", + "Import CLIProxyAPI JSON": "导入 CLIProxyAPI JSON", "Import Token": "导入令牌", + "Importing...": "导入中...", "In": "输入", "In / Out": "输入/输出", "Inactive": "未激活", + "Inactive pools are ignored by runtime resolution.": "未激活的代理池将被运行时解析忽略。", "Inc. All rights reserved.": "公司。保留所有权利。", + "Initializing...": "初始化中...", "Input": "输入", + "Input Cost": "输入成本", "Input Tokens": "输入 Token", "Input Tokens:": "输入 Token:", "Input:": "输入:", + "Install 9Router": "安装 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "安装 9Router,通过 Web 仪表盘配置您的提供商,然后开始路由 AI 请求。", + "Install Chrome extension": "安装 Chrome 扩展", + "Install Cline VS Code extension or CLI from": "从以下位置安装 Cline VS Code 扩展或 CLI", + "Install Kilo Code from": "从以下位置安装 Kilo Code", + "Install Qwen Code": "安装 Qwen Code", + "Install Tailscale": "安装 Tailscale", + "Install command:": "安装命令:", + "Install jcode to enable automatic configuration:": "安装 jcode 以启用自动配置:", + "Install the Amp CLI using the package manager supported by your environment.": "使用您环境支持的包管理器安装 Amp CLI。", + "Install then click Start:": "安装后点击启动:", + "Install via npm:": "通过 npm 安装:", "Installation Guide": "安装指南", + "Installing Tailscale...": "正在安装 Tailscale...", "Interactive diagram visible on desktop": "桌面上可见的交互式图表", + "Intercept CLI tool traffic and route through 9Router": "拦截 CLI 工具流量并通过 9Router 路由", "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "通过 DNS 重定向拦截Antigravity流量,让您可以通过 9Router 重新路由模型。", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "拦截 Claude Code 的主题命名请求并在本地返回伪响应,节省 API Token。", + "Invalid": "无效", + "Invalid password": "密码错误", + "Issuer URL": "发行者 URL", + "JSON Response": "JSON 响应", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "加入正在使用 9Router 简化 AI 集成的开发者行列。开源且免费。", + "Judge": "裁判", + "Just now": "刚刚", "KB per field": "每个字段的 KB", + "Keep the legacy password login.": "保留原有密码登录方式。", "Key Name": "密钥名称", + "KiRo dashboard": "KiRo 仪表盘", + "Kill & Start": "终止并启动", + "Kill this process to start MITM Server?": "终止此进程以启动 MITM 服务器?", + "Kilo Code - Manual Configuration": "Kilo Code - 手动配置", + "Kilo Code AI Assistant": "Kilo Code AI 助手", + "Kilo Code not detected locally": "未在本地检测到 Kilo Code", "Kimi": "Kimi", "Kiro AI": "Kiro AI", "Kiro IDE not detected. Please paste your refresh token manually.": "未检测到 Kiro IDE。请手动粘贴您的刷新令牌。", - "Last updated:": "最后更新:", + "Kiro IDE with MITM": "Kiro IDE 与 MITM", + "Language": "语言", + "Languages": "语言", + "Last Page": "末页", "Last Used": "最后使用", + "Last tested:": "上次测试:", + "Last updated:": "最后更新:", "Latency": "延迟", "Latency:": "延迟:", + "Lazy senior dev": "懒人高级开发者", "Lean": "Lean", + "Leave blank to keep existing secret": "留空以保留现有密钥", + "Leave blank to use": "留空以使用", + "Leave empty for public PKCE app": "公开 PKCE 应用请留空", "Leave empty to inherit existing env proxy (if any).": "留空以继承现有的 env 代理(如果有)。", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "API 仍接受旧版手动代理字段以实现向后兼容。", + "Legacy:": "旧版:", + "Legal": "法律", + "Live server console output": "服务器实时控制台输出", "Load": "加载", "Loading logs...": "正在加载日志...", "Loading models from provider...": "正在从提供商处加载模型...", "Loading pricing data...": "正在加载定价数据...", + "Loading registry...": "正在加载注册表...", + "Loading reset credits...": "正在加载重置信用...", + "Loading...": "加载中...", + "Local": "本地", "Local Mode": "本地模式", "Local Mode - All data stored on your machine": "本地模式 - 所有数据都存储在您的计算机上", + "Local Plugins": "本地插件", + "Locked. Retry in": "已锁定。请在", + "Login": "登录", + "Login Button Label": "登录按钮标签", + "Login URL": "登录 URL", "Login to your account": "登录您的账号", "Login with your GitHub account (manual callback).": "使用您的 GitHub 账号登录(手动回调)。", "Login with your Google account (manual callback).": "使用您的 Google 账号登录(手动回调)。", + "Logout": "退出登录", + "Logs": "日志", + "Logs are loaded from the request history database.": "日志从请求历史数据库中加载。", "Logs are saved to log.txt in the application data directory.": "日志保存在应用程序数据目录下的log.txt中。", + "MIT License": "MIT 许可证", + "MITM": "MITM", + "MITM Proxy": "MITM 代理", + "MITM Server": "中间人服务器", + "MITM Tools": "MITM 工具", "Machine ID": "机器ID", "Machine ID will be auto-filled...": "机器 ID 将自动填充...", - "macOS / Linux / Windows:": "macOS / Linux / Windows:", - "macOS / Linux:": "macOS / Linux:", + "Make sure Cursor IDE has been opened at least once, then click": "请确保 Cursor IDE 至少已打开过一次,然后点击", + "Manage": "管理", + "Manage reusable per-connection proxies and bind them to provider connections.": "管理可复用的连接代理并绑定到提供商连接。", + "Manage your AI provider connections": "管理您的 AI 提供商连接", + "Manage your Embedding providers": "管理您的嵌入提供商", + "Manage your Image to Text providers": "管理您的图像转文本提供商", + "Manage your Music providers": "管理您的音乐提供商", + "Manage your Speech To Text providers": "管理您的语音转文本提供商", + "Manage your Text To Speech providers": "管理您的文本转语音提供商", + "Manage your Text to Image providers": "管理您的文本转图像提供商", + "Manage your Video providers": "管理您的视频提供商", + "Manage your Web Fetch providers": "管理您的 Web 抓取提供商", + "Manage your Web Search providers": "管理您的 Web 搜索提供商", + "Manage your preferences": "管理您的偏好设置", + "Manage your proxy pool configurations": "管理您的代理池配置", + "Manual / current endpoint": "手动/当前端点", "Manual Callback Required": "需要手动回调", "Manual Config": "手动配置", + "Manual configuration is still available if 9router is deployed on a remote server.": "如果 9router 部署在远程服务器上,仍可使用手动配置。", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "将 Amp 简写名称(如 g25p 或 cs45)映射到本地配置中的 9Router 别名。", + "Mask (URL)": "遮罩(URL)", "Max JSON Size (KB)": "最大 JSON 大小 (KB)", "Max Records": "最大记录数", "Maximum request detail records to keep (older records are auto-deleted)": "要保留的最大请求详细记录(较旧的记录会自动删除)", "Maximum size for each JSON field (request/response) before truncation": "截断前每个 JSON 字段(请求/响应)的最大大小", "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "刷新缓冲区之前等待的最长时间(防止低流量期间数据丢失)", + "Media Providers": "媒体提供商", + "Menu": "菜单", + "Message AI": "向 AI 发送消息", "Messages": "消息", + "Messages API": "消息 API", "MiniMax": "MiniMax", - "MITM Server": "中间人服务器", "Model": "模型", + "Model Fallback": "模型回退", "Model ID": "模型 ID", "Model ID (from OpenRouter)": "模型 ID(来自 OpenRouter)", - "Model is reachable": "模型可达", - "Model mappings will be available soon.": "模型映射即将推出。", + "Model ID (optional)": "模型 ID(可选)", "Model Status": "模型状态", + "Model combos": "模型组合", + "Model combos with fallback": "模型组合及回退", + "Model is reachable": "模型可达", + "Model list is filtered from connected providers.": "模型列表已从已连接的提供商中筛选。", + "Model mappings will be available soon.": "模型映射即将推出。", + "Model not reachable": "模型不可达", "Model:": "模型:", "Models": "模型", - "more providers": "更多提供商", + "Monitor your API usage, token consumption, and request logs": "监控您的 API 使用量、Token 消耗和请求日志", + "More on GitHub": "在 GitHub 上查看更多", "Move down": "下移", "Move up": "向上移动", - "ms / Total": "毫秒/总计", + "Music": "音乐", + "My Profile": "我的资料", + "N/A": "无", + "NPM": "NPM", "Name": "名称", + "Name is required": "名称为必填项", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "原生支持 Cursor、Claude、Copilot 等 CLI 工具。", + "Navigate to home": "导航到首页", "Network": "网络", + "Network Error": "网络错误", + "Network error": "网络错误", + "Never": "从未", "New Password": "新密码", + "New password": "新密码", + "Next": "下一页", + "Next accounts page": "下一页账号", + "No API keys - Create one in Keys page": "暂无 API 密钥 - 请在密钥页面创建", + "No API keys yet": "还没有 API 密钥", + "No MCPs added": "未添加 MCP", + "No Providers Connected": "没有连接提供商", + "No Proxy": "无代理", "No active connections found for this group.": "未找到该组的活跃连接。", "No active providers": "没有活跃的提供商", - "No API keys yet": "还没有 API 密钥", + "No active proxy pools available. Create one in Proxy Pools page first.": "没有可用的活跃代理池。请先在代理池页面创建一个。", + "No authentication required": "无需身份验证", "No combos yet": "还没有组合", + "No combos yet.": "暂无组合。", "No compatible providers added yet": "尚未添加兼容的提供商", "No connections": "无连接", "No connections yet": "还没有连接", "No console logs yet.": "还没有控制台日志。", + "No conversations yet.": "暂无对话。", + "No custom providers": "没有自定义提供商", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "没有自定义提供商 — 使用上方按钮添加 OpenAI/Anthropic 兼容端点", "No data for this period": "此期间没有数据", + "No key configured": "未配置密钥", + "No language selected": "未选择语言", + "No languages found.": "未找到语言。", "No logs recorded yet.": "尚未记录任何日志。", + "No model selected.": "未选择模型。", "No models": "暂无模型", "No models added yet": "尚未添加模型", "No models configured": "未配置模型", "No models found": "未找到模型", "No models match your filter.": "没有模型匹配您的筛选条件。", + "No models selected": "未选择模型", + "No port forwarding needed": "无需端口转发", "No pricing data available": "无可用定价数据", - "No Providers Connected": "没有连接提供商", - "No Proxy": "无代理", + "No providers connected": "没有连接提供商", + "No providers match your search": "没有匹配搜索结果的提供商", + "No providers support": "没有提供商支持", + "No providers yet.": "暂无提供商。", + "No providers.": "暂无提供商。", + "No proxy pool entries yet": "暂无代理池条目", + "No proxy:": "无代理:", "No quota data available": "无可用配额数据", "No request details found": "未找到请求详细信息", "No requests yet.": "暂无请求。", + "No reset credit details returned for this account.": "此账号没有返回重置信用详情。", + "No results": "无结果", + "No servers match filter": "没有匹配筛选条件的服务器", + "No tools advertised by server.": "服务器未公布任何工具。", + "No usage yet.": "暂无使用记录。", + "None": "无", + "None (unbind all)": "无(解除全部绑定)", "Not configured": "未配置", + "Not installed": "未安装", + "Notice": "提示", + "Nous Research self-improving AI agent": "Nous Research 自我改进的 AI 代理", "Number of items to accumulate before writing to database (higher = better performance)": "写入数据库之前要累积的项目数(越高=性能越好)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth 和 API 密钥", + "OAuth Account": "OAuth 账号", + "OAuth App": "OAuth 应用", "OAuth Providers": "OAuth 提供商", + "OAuth required": "需要 OAuth", + "OIDC Dashboard Login": "OIDC 仪表盘登录", + "OIDC active": "OIDC 已激活", + "OIDC login is currently active. Password login is disabled until you switch back.": "OIDC 登录当前已激活。密码登录已禁用,直到您切换回来。", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "OIDC 登录已启用,但发行者/客户端字段尚未配置。密码登录仍可用于恢复。", + "OIDC only": "仅 OIDC", "Observability": "可观察性", + "Office Proxy": "办公代理", + "Ollama Host URL": "Ollama 主机 URL", + "One Endpoint for": "统一端点,接入", + "One key per line. Format:": "每行一个密钥。格式:", + "One-to-one (rotate)": "一对一(轮换)", + "Only from connected providers": "仅来自已连接的提供商", "Only letters, numbers, - and _ allowed": "只允许使用字母、数字、- 和 _", + "Only letters, numbers, -, _ and .": "仅允许使用字母、数字、-、_ 和 .", + "Only letters, numbers, -, _ and . allowed": "仅允许使用字母、数字、-、_ 和 .", "Only one connection is allowed per compatible node. Add another node if you need more connections.": "每个兼容节点仅允许一个连接。如果需要更多连接,请添加另一个节点。", + "Open": "打开", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "打开 Claude Desktop → 帮助 → 故障排除 → 启用开发者模式 → 配置第三方推理,然后返回此处。", "Open Claw - Manual Configuration": "Open Claw - 手动配置", + "Open Claw AI Assistant": "Open Claw AI 助手", + "Open Claw CLI not detected locally": "未在本地检测到 Open Claw CLI", "Open Claw CLI not installed": "未安装 Open Claw CLI", + "Open Continue configuration file": "打开 Continue 配置文件", + "Open Dashboard": "打开仪表盘", "Open DevTools (F12) → Application/Storage → Cookies": "打开 DevTools (F12) → 应用程序/存储 → Cookie", + "Open Settings": "打开设置", "Open platform.iflow.cn in your browser": "在浏览器中打开platform.iflow.cn", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "OpenAI / ElevenLabs / Edge / Google / Deepgram 语音。", + "OpenAI Codex CLI": "OpenAI Codex CLI", "OpenAI Compatible (Prod)": "OpenAI 兼容(生产)", + "OpenAI Compatible Details": "OpenAI 兼容详情", + "OpenAI Intermediate": "OpenAI 中间格式", + "OpenAI Response": "OpenAI 响应", "OpenCode - Manual Configuration": "OpenCode - 手动配置", + "OpenCode AI Terminal Assistant": "OpenCode AI 终端助手", + "OpenCode CLI not detected locally": "未在本地检测到 OpenCode CLI", "OpenCode CLI not installed": "未安装 OpenCode CLI", "OpenRouter": "OpenRouter", "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter 支持任何模型。添加模型并创建别名以便快速访问。", + "Optional SSO via Authentik/Keycloak/Google": "可选通过 Authentik/Keycloak/Google 的 SSO", + "Or paste callback URL manually": "或手动粘贴回调 URL", + "Organization": "组织", + "Organization Domain": "组织域名", + "Organization ID": "组织 ID", + "Organization Token": "组织令牌", + "Organization Tokens": "组织令牌", "Other": "其他", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "我们的引擎分析提示词并通过您的订阅、低价和免费提供商层级路由,自动回退。", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "我们的引擎分析提示词,检查提供商健康状态,并路由到最低延迟或成本的提供商。", "Out": "输出", "Outbound Proxy": "出站代理", "Output": "输出", + "Output Cost": "输出成本", + "Output Format": "输出格式", "Output Tokens": "输出 Token", "Output Tokens:": "输出 Token:", "Output:": "输出:", + "Overview": "概览", + "Paid": "付费", + "Partial preview": "部分预览", + "Password": "密码", + "Password + OIDC active": "密码 + OIDC 已激活", + "Password and OIDC login are both active.": "密码和 OIDC 登录均已激活。", + "Password and OIDC login are both enabled.": "密码和 OIDC 登录均已启用。", + "Password only": "仅密码", "Password updated successfully": "密码更新成功", "Passwords do not match": "密码不匹配", + "Paste Proxy List (One per line)": "粘贴代理列表(每行一个)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "粘贴一个长期有效的 Kiro/CodeWhisperer API 密钥。它将通过 AWS 验证并直接存储为 Bearer 凭据(无需刷新)。", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "从 CLIProxyAPI/Kiro Microsoft 登录粘贴 external_idp 认证 JSON。", "Paste it below": "粘贴在下面", "Paste refresh token from Kiro IDE.": "从 Kiro IDE 粘贴刷新令牌。", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "粘贴包含 auth_method=external_idp 的 Kiro CLIProxyAPI 认证 JSON。仅接受 Microsoft 登录令牌端点。", + "Paste the URL from your browser address bar": "从浏览器地址栏粘贴 URL", + "Paste the command into your terminal and press Enter.": "将命令粘贴到终端中并按 Enter。", + "Paste this to your AI:": "将其粘贴到您的 AI:", + "Paste your Kiro API key...": "粘贴您的 Kiro API 密钥...", + "Pause API Key": "暂停 API 密钥", + "Pause key": "暂停密钥", "Paused": "已暂停", - "Please add and connect providers first to configure CLI tools.": "请先添加并连接提供商以配置 CLI 工具。", + "Permissions": "权限", + "Personal Access Token": "个人访问令牌", + "Pick the model that fuses panel answers": "选择用于融合面板回答的模型", "Please add an active Qoder connection first": "请先添加一个活跃的 Qoder 连接", + "Please add and connect providers first to configure CLI tools.": "请先添加并连接提供商以配置 CLI 工具。", "Please copy the URL from the address bar and paste it in the application.": "请复制地址栏中的 URL 并将其粘贴到应用程序中。", "Please enter a Proxy URL to test": "请输入代理 URL 进行测试", "Please install Claude CLI to use this feature.": "请安装 Claude CLI 才能使用此功能。", @@ -338,8 +866,17 @@ "Please install Open Claw CLI to use this feature.": "请安装 Open Claw CLI 才能使用此功能。", "Please install OpenCode CLI to use auto-apply feature.": "请安装 OpenCode CLI 以使用自动应用功能。", "Please wait while we complete the authorization.": "我们正在完成授权,请稍候。", + "Point your CLI tools to http://localhost:20128": "将您的 CLI 工具指向 http://localhost:20128", + "Pool:": "代理池:", "Popup blocked? Enter URL manually": "弹出窗口被拦截?请手动输入 URL", + "Port 443 Already In Use": "端口 443 已被占用", + "Port 443 is currently used by another process:": "端口 443 当前被另一个进程占用:", + "Powerful Features": "强大功能", "Prefix": "前缀", + "Preset": "预设", + "Prev": "上一页", + "Preview": "预览", + "Previous accounts page": "上一页账号", "Pricing": "定价", "Pricing Configuration": "定价配置", "Pricing Format:": "定价格式:", @@ -347,427 +884,510 @@ "Pricing Settings": "定价设置", "Priority": "优先级", "Privacy Policy": "隐私政策", + "Probing server for tools...": "正在探测服务器工具...", + "Processing...": "处理中...", "Product": "产品", "Production Key": "生产密钥", + "Project Name": "项目名称", + "Prompt": "提示", "Provider": "提供商", + "Provider Details": "提供商详情", "Provider Limits": "提供商限制", + "Provider Response": "提供商响应", "Provider not found": "未找到提供商", + "Provider test failed": "提供商测试失败", "Provider:": "提供商:", "Providers": "提供商", - "Proxy settings applied": "已应用代理设置", + "Proxy": "代理", + "Proxy Action": "代理操作", + "Proxy Pool": "代理池", + "Proxy Pools": "代理池", "Proxy URL": "代理 URL", + "Proxy disabled": "代理已禁用", + "Proxy enabled": "代理已启用", + "Proxy pool created": "代理池已创建", + "Proxy pool deleted": "代理池已删除", + "Proxy pool updated": "代理池已更新", + "Proxy settings applied": "已应用代理设置", + "Proxy test OK": "代理测试通过", + "Proxy test failed": "代理测试失败", + "Proxy test passed": "代理测试通过", "Purpose:": "用途:", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "本地管理模式需要 Python >= 3.10。请先安装 Python,或使用外部代理 URL。", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "本地管理模式需要 Python ≥ 3.10。请先安装 Python,或使用外部代理 URL。", + "Quota Tracker": "配额跟踪器", "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code 通过 settings.json 中的 modelProviders 支持多种提供商类型(openai、anthropic、gemini)。9Router 作为 OpenAI 兼容端点工作。", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "Qwen OAuth 免费套餐已于 2026-04-15 停止。请改用带有 alicode/openrouter/anthropic/gemini 提供商的 9Router。", + "Rate Limited": "被限流", + "Read Documentation": "阅读文档", "Reading from AWS SSO cache": "从 AWS SSO 缓存中读取", "Reading from Cursor IDE database": "从 Cursor IDE 数据库读取", + "Ready": "就绪", + "Ready to Simplify Your AI Infrastructure?": "准备简化您的 AI 基础设施?", + "Ready to route! ✓": "准备就绪,可以路由!✓", + "Ready! Requests route automatically through your configured providers.": "就绪!请求将自动通过您配置的提供商路由。", "Reasoning": "推理", "Reasoning:": "推理:", "Recent Requests": "最近的请求", + "Recent chats": "最近聊天", + "Recheck": "重新检查", "Recommended for most users. Free AWS account required.": "推荐给大多数用户。需要免费 AWS 帐户。", - "records, batches every": "记录,每批", + "Record request details for inspection in the logs view": "记录请求详情以在日志视图中查看", + "Redirect URI": "重定向 URI", + "Ref Image (URL)": "参考图片(URL)", "Refresh": "刷新", "Refresh All": "全部刷新", - "Refresh quota": "刷新配额", "Refresh Token": "刷新令牌", + "Refresh all": "全部刷新", + "Refresh quota": "刷新配额", + "Region": "区域", + "Reload Page": "重新加载页面", "Reload VS Code after applying for changes to take effect.": "应用更改后请重新加载 VS Code 以使其生效。", + "Remaining": "剩余", + "Remote": "远程", "Remove": "移除", + "Remove attachment": "移除附件", "Remove custom model": "删除自定义模型", "Remove model": "删除模型", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "替换内置的 WebSearch/WebFetch。自动从工具列表中去除重复项。", + "Replay request flow — matches log files": "重放请求流程——匹配日志文件", + "Request": "请求", "Request Details": "请求详情", "Request Logs": "请求日志", "Requests": "请求", "Requests without a valid key will be rejected": "没有有效密钥的请求将被拒绝", - "requests, max": "请求数,最大", "Require API key": "需要 API 密钥", + "Require OIDC for dashboard access.": "需要 OIDC 才能访问仪表盘。", "Require login": "需要登录", "Required for SSL certificate and DNS configuration": "SSL 证书和 DNS 配置所需", "Required for SSL certificate and server startup": "SSL 证书和服务器启动所需", "Required to modify /etc/hosts and flush DNS cache": "需要修改 /etc/hosts 并刷新 DNS 缓存", + "Required. A friendly label for this node.": "必填。为此节点设置一个友好的显示名称。", + "Required. Used as the provider prefix for model IDs.": "必填。用作模型 ID 的提供商前缀。", + "Requires \"Workers Scripts: Edit\" permission.": "需要 \"Workers Scripts: Edit\" 权限。", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "需要 Cloudflare 账号 ID 和 Workers API 令牌(编辑 Workers 权限)", + "Requires Cursor Pro account to use this feature.": "需要 Cursor Pro 账号才能使用此功能。", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "需要安装 jcode。安装方式:curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "需要出站端口 7844 (TCP/UDP)。连接可能需要 10-30 秒。", "Reset": "重置", - "Reset to default": "重置为默认值", + "Reset Codex limit?": "重置 Codex 限制?", + "Reset Password to Default": "重置密码为默认值", + "Reset judge to Auto": "重置裁判为自动", + "Reset time": "重置时间", "Reset to Defaults": "重置为默认值", + "Reset to default": "重置为默认值", "Resources": "资源", + "Response": "响应", + "Response Format": "响应格式", + "Responses": "响应", "Responses API": "响应API", + "Restart": "重新启动", + "Restore model": "恢复模型", + "Resume key": "恢复密钥", "Retry": "重试", + "Risk Notice": "风险提示", + "Roo AI Assistant": "Roo AI 助手", + "Rotate providers across requests instead of strict fallback order.": "在请求间轮换提供商,而不是严格的回退顺序。", "Round Robin": "轮询", + "Round Robin — rotate": "轮询 — 轮换", + "Round Robin — rotates models across requests to spread load": "轮询 — 在请求间轮换模型以分散负载", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "通过订阅、低价和免费层级路由 AI 请求并自动回退。一个端点接入 Claude、GPT、Gemini 等。", + "Route Requests": "路由请求", "Routing Strategy": "路由策略", "Rows:": "行:", + "Run": "运行", + "Run npx command to start the server instantly": "运行 npx 命令立即启动服务器", "Run this command in your terminal, then click": "在终端中运行此命令,然后单击", "Running": "运行中", "Running on your machine": "在你的机器上运行", - "s)": ")", + "Runtime": "运行时", + "SSE URL": "SSE URL", + "START HERE": "从这里开始", + "Save": "保存", + "Save Changes": "保存更改", + "Save Config": "保存配置", "Save Mappings": "保存映射", + "Save auth mode": "保存认证模式", + "Save current Base URL and API key as a browser-local preset": "将当前基础 URL 和 API 密钥保存为浏览器本地预设", "Save this key now!": "立即保存此密钥!", + "Saved": "已保存", + "Saving": "保存中", + "Saving...": "保存中...", + "Scan QR to connect instantly": "扫描二维码即刻连接", + "Scopes": "权限范围", + "Screen sharing": "屏幕共享", + "Scroll down to": "向下滚动到", + "Search by name or description...": "按名称或描述搜索...", + "Search language...": "搜索语言...", "Search model id": "搜索模型 ID", + "Search providers...": "搜索提供商...", + "Search...": "搜索...", "Security": "安全", + "Security required: ": "安全要求:", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "安全风险:未设置密码。远程登录时将要求您设置一个密码。", "Select": "选择", - "Select a provider": "选择提供商", - "Select all": "选择全部", + "Select All": "全选", + "Select Cowork Model": "选择 Cowork 模型", + "Select Endpoint": "选择端点", + "Select Judge Model": "选择裁判模型", + "Select Language": "选择语言", "Select Model": "选择模型", + "Select Model for Cline": "选择 Cline 模型", "Select Model for Codex": "选择 Codex 模型", + "Select Model for DeepSeek TUI": "选择 DeepSeek TUI 模型", "Select Model for Factory Droid": "选择 Factory Droid 模型", "Select Model for GitHub Copilot": "选择 GitHub Copilot 模型", + "Select Model for Hermes Agent": "选择 Hermes Agent 模型", + "Select Model for Kilo Code": "选择 Kilo Code 模型", "Select Model for Open Claw": "选择 Open Claw 模型", "Select Model for OpenCode": "选择 OpenCode 模型", + "Select Model for jcode": "选择 jcode 模型", + "Select Provider": "选择提供商", + "Select Subagent Model for Codex": "选择 Codex 子代理模型", + "Select Subagent Model for OpenCode": "选择 OpenCode 子代理模型", + "Select a provider": "选择提供商", + "Select all": "选择全部", + "Select language": "选择语言", + "Select models to add": "选择要添加的模型", + "Select one or more connections, then click Proxy Action.": "选择一个或多个连接,然后点击代理操作。", + "Select to pre-fill, then edit model ID in the input": "选择以预填充,然后在输入框中编辑模型 ID", + "Select your": "选择您的", + "Selected connections have mixed proxy bindings": "所选连接的代理绑定状态不一致", "Selected only": "仅选定", "Selected provider": "选定的提供商", + "Selecting None will unbind selected connections from proxy pool.": "选择「无」将解除所选连接与代理池的绑定。", + "Send": "发送", "Send to Provider": "发送给提供商", "Sent to provider as:": "发送给提供商:", "Server": "服务器", + "Server Disconnected": "服务器已断开", "Server off": "服务器关闭", + "Server running on": "服务器运行在", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "服务正在终端中运行。您可以关闭此网页。关闭将停止服务。", + "Set Password": "设置密码", + "Set a new password before accessing the dashboard remotely.": "在远程访问仪表盘之前设置一个新密码。", + "Set password": "设置密码", + "Setting password for the first time. Leave current password empty or use default:": "首次设置密码。将当前密码留空或使用默认值:", "Setting up": "设置", + "Settings": "设置", + "Settings applied successfully!": "设置已成功应用!", + "Settings reset successfully!": "设置已成功重置!", + "Setup": "设置", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "所有功能的设置和索引。从这里开始 — 涵盖基础 URL、认证、模型发现,并链接到每个功能技能。", "Share Endpoint": "共享端点", "Share URL with team members": "与团队成员共享 URL", + "Show": "显示", + "Show all": "显示全部", + "Show key": "显示密钥", "Show only selected models": "仅显示选中的模型", "Showing": "显示中", + "Shutdown": "关闭", + "Sign in with OIDC": "使用 OIDC 登录", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "简单的聊天界面,与已连接提供商的任何 AI 模型交互。选择一个模型开始聊天!", + "Single": "单个", + "Single API endpoint for all major AI providers. Simplify your integration.": "一个 API 端点接入所有主要 AI 提供商。简化集成。", + "Some models are not responding": "部分模型未响应", + "Sort Codex quotas by remaining": "按剩余量排序 Codex 配额", + "Sort accounts by earliest quota reset time": "按最早配额重置时间排序账号", + "Source Body": "源请求体", + "Sourcegraph Amp coding assistant CLI": "Sourcegraph Amp 编程助手 CLI", "Special reasoning/thinking tokens (fallback to output rate)": "特殊推理/思考 Token(回退至输出费率)", + "Speech To Text": "语音转文本", + "Speech-to-Text": "语音转文本", "Standard prompt tokens": "标准提示 Token", - "Start Date": "开始日期", "Start DNS": "启动 DNS", + "Start Date": "开始日期", + "Start Free": "免费开始", + "Start Headroom": "启动 Headroom", + "Start Headroom separately at the configured URL, then recheck.": "在配置的 URL 上单独启动 Headroom,然后重新检查。", "Start MITM": "启动中间人", "Start Server": "启动服务器", "Start Tunnel": "开始隧道", + "Start a conversation": "开始一个对话", + "Starting 9Router...": "正在启动 9Router...", "Status": "状态", "Status:": "状态:", "Step 1: Open this URL in your browser": "第 1 步:在浏览器中打开此 URL", "Step 2: Paste the callback URL here": "第 2 步:将回调 URL 粘贴到此处", "Sticky Limit": "粘性限制", + "Sticky:": "粘滞:", + "Stop": "停止", "Stop DNS": "停止 DNS", + "Stop Headroom": "停止 Headroom", "Stop MITM": "停止中间人", "Stop Server": "停止服务器", "Stopped": "已停止", + "Strict Proxy": "严格代理", + "Style": "风格", + "Subagent Model": "子代理模型", "Sudo Password Required": "需要 sudo 密码", + "Sudo password is required": "需要 sudo 密码", + "Suggested free models (≥200k context):": "推荐的免费模型(≥200k 上下文):", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "建议的简写示例:g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929。", + "Support up to 20 active apps & 50 custom domains": "支持最多 20 个活跃应用和 50 个自定义域名", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "支持的格式:protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。", + "System": "系统", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel 将停止。通过 Tailscale URL 的远程访问将停止工作。", + "Tailscale installed": "Tailscale 已安装", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale 未安装。请安装它以启用 Funnel。", + "Target Request": "目标请求", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com。", + "Temperature": "温度", + "Terminal": "终端", "Terms of Service": "服务条款", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "简洁风格系统提示 → ~减少 65% 的输出 Token(最高 87%)", "Test": "测试", + "Test Again": "再次测试", + "Test All": "全部测试", + "Test Example": "测试示例", + "Test Results": "测试结果", "Test all API Key connections": "测试所有 API 密钥连接", "Test all Compatible connections": "测试所有兼容连接", "Test all Free connections": "测试所有免费连接", "Test all Free provider connections": "测试所有免费提供商连接", "Test all OAuth connections": "测试所有 OAuth 连接", + "Test connection": "测试连接", "Test model": "测试模型", + "Test proxy": "测试代理", "Test proxy URL": "测试代理 URL", - "Test Results": "测试结果", + "Testing...": "测试中...", + "Text To Speech": "文本转语音", + "Text To Speech combo": "文本转语音组合", + "Text to Image": "文本转图像", + "Text to Image combo": "文本转图像组合", + "Text-to-Speech": "文本转语音", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "通过 DALL-E、Imagen、FLUX、MiniMax、SDWebUI 等进行文本到图像生成。", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare 隧道将被断开。通过隧道 URL 的远程访问将停止工作。", + "The proxy server has been stopped.": "代理服务器已停止。", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "请求由 OpenAI、Anthropic、Gemini 或其他提供商即时响应。", "The tunnel will be disconnected. Remote access will stop working.": "隧道将被断开。远程访问将停止工作。", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "统一的 AI 生成端点。轻松连接、路由和管理您的 AI 提供商。", + "The unified interface for modern AI infrastructure": "现代 AI 基础设施的统一接口", "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "现代人工智能基础设施的统一接口。安全、可观察且可扩展。", + "Theme": "主题", + "Thinking": "思考", "Thinking Process": "思考过程", "This is the only time you will see this key. Store it securely.": "这是您唯一一次看到此密钥的机会。请妥善保管。", + "This provider is ready to use.": "此提供商已准备就绪。", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "此提供商已准备就绪。可选择通过代理池路由请求以绕过 IP 限制。", + "This value is write-only after saving.": "此值保存后仅可写入。", "Timestamp": "时间戳", "Timestamp:": "时间戳:", "To get a fresh API key, paste your browser cookie from": "要获取新的 API 密钥,请粘贴您的浏览器 cookie", - "to verify.": "来验证。", + "Today": "今天", "Toggle DNS to redirect": "切换 DNS 重定向", - "Token auto-detected from Kiro IDE successfully!": "已成功从 Kiro IDE 自动检测到令牌!", + "Toggle auto-ping": "切换自动 Ping", + "Token Saver": "Token 节省器", "Token Types:": "Token 类型:", + "Token auto-detected from Kiro IDE successfully!": "已成功从 Kiro IDE 自动检测到令牌!", + "Token is used once for deployment and not stored.": "令牌仅用于部署一次,不会被存储。", + "Token is used once for deployment, not stored. Found in Organization Settings.": "令牌仅用于部署一次,不会被存储。可在组织设置中找到。", "Token will be auto-filled...": "令牌将自动填充...", "Tokens": "Token", "Tokens auto-detected from Cursor IDE successfully!": "已成功从 Cursor IDE 自动检测到令牌!", "Tokens used to create cache entries (fallback to input rate)": "用于创建缓存条目的 Token(回退至输入费率)", + "Tomorrow": "明天", + "Tool not found or disabled.": "未找到或工具已禁用。", + "Tools": "工具", + "Tools:": "工具:", + "Total Cost": "总成本", "Total Input Tokens": "输入 Token 总计", "Total Models": "模型总数", "Total Requests": "请求总数", + "Total Tokens": "总 Token", "Total:": "总计:", - "traffic through 9Router via MITM.": "通过 MITM 通过 9Router 的流量。", + "Track and manage your API quota limits": "跟踪和管理您的 API 配额限制", + "Track token usage, costs, and performance across all providers.": "跟踪所有提供商的 Token 使用量、成本和性能。", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "通过 OpenAI Whisper、Groq、Gemini、Deepgram、AssemblyAI 等转录音频。", + "Transferring data...": "数据传输中...", + "Translator": "翻译器", "Translator Debug": "翻译器调试", + "Tried in order (top-down) or rotated when round-robin is on.": "按顺序尝试(从上到下)或开启轮询时轮换。", + "Trust Cert": "信任证书", + "Trusted": "已信任", "Try Again": "再试一次", + "Tunnel": "隧道", "Tunnel connected!": "隧道连通!", "Tunnel disabled": "隧道已禁用", + "Turn off Empty": "关闭空账号", + "Turn on Available": "开启可用账号", "Turn request detail recording on/off globally": "全局打开/关闭请求详细信息记录", "Twitter": "Twitter", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → 通过 Firecrawl、Jina、Tavily、Exa 转换为 markdown / text / HTML。", "Unavailable": "不可用", + "Under": "在", + "Unified Endpoint": "统一端点", "Unknown": "未知", "Unselect all": "取消选择全部", "Update": "更新", - "Usage by Account": "按账号统计", + "Update 9Router": "更新 9Router", + "Update Password": "修改密码", + "Update now": "立即更新", + "Upstream Auth Error": "上游认证错误", + "Upstream Unavailable": "上游不可用", + "Usage": "使用情况", + "Usage & Analytics": "使用量和分析", + "Usage / Limit": "使用量/限制", + "Usage Logs": "使用日志", + "Usage Tracking": "使用量跟踪", "Usage by API Key": "按 API 密钥统计", + "Usage by Account": "按账号统计", "Usage by Endpoint": "按端点统计", "Usage by Model": "按模型统计", + "Usage:": "用法:", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "使用 9Router 模型别名来保持 Amp 简写映射在提供商更新时稳定。", "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "使用 Antigravity IDE 和 GitHub Copilot → 与 9Router 的任何提供商/模型", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "使用 Authentik 或任何 OIDC 提供商登录仪表盘。", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "使用 Authentik 或任何 OIDC 提供商登录仪表盘。您可以启用仅密码、仅 OIDC 或两者同时启用;模型 API 访问仍使用 API 密钥。", + "Use a GitLab OAuth application": "使用 GitLab OAuth 应用", + "Use a GitLab PAT with api scope": "使用具有 api 范围的 GitLab PAT", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "使用来自 console.x.ai 的直接 xAI API 密钥。这与 Grok Build OAuth 是分开的。", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "使用本地代理进行启动/停止,或使用外部 Docker sidecar 如 http://headroom:8787。", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "使用长期有效的 Kiro/CodeWhisperer API 密钥(无头认证)。", "Use in Cursor/Cline": "在Cursor/Cline中使用", "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "使用上面的按钮添加 OpenAI 或 Anthropic 兼容端点", "Use your API from any network": "从任何网络使用您的 API", + "Valid": "有效", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "通过 OpenAI、Gemini、Mistral 等为 RAG/语义搜索提供向量。", + "Vercel API Token": "Vercel API Token", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel 服务数百万应用 — 提供商无法在不影响合法流量的情况下封禁 Vercel IP", "Verification URL": "验证 URL", + "Video": "视频", + "View Codex reset credit expiry": "查看 Codex 重置信用有效期", "View Full Details": "查看完整详情", + "View on GitHub": "在 GitHub 上查看", "Visit the URL below and enter the code:": "访问以下网址并输入代码:", + "Visit the login URL below and authorize:": "访问下面的登录 URL 并进行授权:", + "Voice": "语音", + "Voice ID": "语音 ID", + "Voyage AI": "Voyage AI", "Waiting for Authorization": "等待授权", "Waiting for authorization...": "等待授权...", "Warning": "警告", + "Web Fetch": "Web 抓取", + "Web Fetch & Search": "Web 搜索与抓取", + "Web Search": "Web 搜索", + "Web Search & Fetch (Exa)": "Web 搜索与抓取(Exa)", + "Welcome": "欢迎", + "What is Cloudflare Relay?": "什么是 Cloudflare Relay?", + "What is Deno Relay?": "什么是 Deno Relay?", + "What is Vercel Relay?": "什么是 Vercel Relay?", "When": "时间", "When ON, dashboard requires password. When OFF, access without login.": "当打开时,仪表板需要密码。当关闭时,无需登录即可访问。", + "Windows:": "Windows:", "Windows: Run 9Router terminal as Administrator": "Windows:以管理员身份运行 9Router 终端", "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows:以管理员身份运行终端 (9Router) 以启用 MITM", + "Worker Name": "Worker 名称", + "Works on any device": "适用于任何设备", "Writes to": "写入到", "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "您可以覆盖特定模型的默认定价。随时重置为默认值以恢复标准费率。", "Your": "你的", + "Your Account Name": "您的账号名称", "Your Code": "你的代码", "Your Kiro account via": "您的 Kiro 帐户通过", + "Your OAuth application client ID": "您的 OAuth 应用客户端 ID", "Your organization's AWS IAM Identity Center URL": "您组织的 AWS IAM Identity Center URL", - "Quota Tracker": "配额跟踪器", - "CLI Tools": "命令行工具", - "Console Log": "控制台日志", - "System": "系统", - "Debug": "调试", - "Settings": "设置", - "Usage": "使用情况", - "Shutdown": "关闭", - "Close Proxy": "关闭代理", - "Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?", - "Server Disconnected": "服务器已断开", - "The proxy server has been stopped.": "代理服务器已停止。", - "Reload Page": "重新加载页面", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "服务正在终端中运行。您可以关闭此网页。关闭将停止服务。", - "One Endpoint for": "统一端点,接入", - "All AI Providers": "所有 AI 提供商", - "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "通过订阅、低价和免费层级路由 AI 请求并自动回退。一个端点接入 Claude、GPT、Gemini 等。", - "Get Started": "开始使用", - "View on GitHub": "在 GitHub 上查看", - "How 9Router Works": "9Router 工作原理", - "Data flows seamlessly through our intelligent routing system": "数据通过我们的智能路由系统无缝流转", - "1. CLI & SDKs": "1. CLI 和 SDK", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "您的请求从您最熟悉的工具或我们的统一 SDK 发起。只需更改基础 URL。", "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "请求从您常用的工具发起——Cursor、Claude、Copilot 或任何 OpenAI 兼容的 SDK。", - "2. 9Router Hub": "2. 9Router 枢纽", - "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "我们的引擎分析提示词并通过您的订阅、低价和免费提供商层级路由,自动回退。", - "3. AI Providers": "3. AI 提供商", - "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "请求由 OpenAI、Anthropic、Gemini 或其他提供商即时响应。", - "Powerful Features": "强大功能", - "Everything you need to manage your AI infrastructure efficiently.": "高效管理 AI 基础设施所需的一切。", - "Unified Endpoint": "统一端点", - "Single API endpoint for all major AI providers. Simplify your integration.": "一个 API 端点接入所有主要 AI 提供商。简化集成。", - "Easy Setup": "简单设置", - "Get started in seconds. Just install, open, and route.": "几秒钟即可上手。安装、打开、路由。", - "Model Fallback": "模型回退", - "Automatically switch between providers when limits are hit.": "当达到限额时自动切换提供商。", - "Usage Tracking": "使用量跟踪", - "Track token usage, costs, and performance across all providers.": "跟踪所有提供商的 Token 使用量、成本和性能。", - "OAuth & API Keys": "OAuth 和 API 密钥", - "Connect via OAuth or API keys. Securely manage credentials.": "通过 OAuth 或 API 密钥连接。安全管理凭据。", - "Cloud Sync": "云端同步", - "Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。", - "CLI Support": "CLI 支持", - "Native CLI tool support for Cursor, Claude, Copilot, and more.": "原生支持 Cursor、Claude、Copilot 等 CLI 工具。", - "Dashboard": "仪表盘", - "Beautiful web dashboard for managing providers and monitoring usage.": "精美的 Web 仪表盘,用于管理提供商和监控使用情况。", - "Get Started in 30 Seconds": "30 秒快速上手", - "Install 9Router": "安装 9Router", - "Open Dashboard": "打开仪表盘", - "Route Requests": "路由请求", - "npm install -g 9router": "npm install -g 9router", - "open http://localhost:9099": "open http://localhost:9099", - "Ready! Requests route automatically through your configured providers.": "就绪!请求将自动通过您配置的提供商路由。", - "How it Works": "工作原理", - "Docs": "文档", - "GitHub": "GitHub", - "Legal": "法律", - "Manage your AI provider connections": "管理您的 AI 提供商连接", - "Model combos with fallback": "模型组合及回退", - "Monitor your API usage, token consumption, and request logs": "监控您的 API 使用量、Token 消耗和请求日志", - "Track and manage your API quota limits": "跟踪和管理您的 API 配额限制", - "Intercept CLI tool traffic and route through 9Router": "拦截 CLI 工具流量并通过 9Router 路由", - "Configure CLI tools": "配置 CLI 工具", - "Manage your proxy pool configurations": "管理您的代理池配置", - "API endpoint configuration": "API 端点配置", - "Manage your preferences": "管理您的偏好设置", - "Live server console output": "服务器实时控制台输出", - "Usage & Analytics": "使用量和分析", - "MITM Proxy": "MITM 代理", - "Translator": "翻译器", - "Media Providers": "媒体提供商", - "Theme": "主题", - "Remote": "远程", - "Logout": "退出登录", - "Change Log": "更新日志", - "Proxy Pools": "代理池", - "MITM": "MITM", - "Loading...": "加载中...", - "Enter your password to access the dashboard": "输入密码以访问仪表盘", - "Password": "密码", - "Enter password": "输入密码", - "Login": "登录", - "Default password is 123456": "默认密码为 123456", - "Invalid password": "密码错误", - "An error occurred. Please try again.": "发生错误,请重试。", - "light": "浅色", - "dark": "深色", - "system": "跟随系统", - "Combo Round Robin": "组合轮询", - "Cycle through providers in combos instead of always starting with first": "在组合中循环使用提供商,而不是总是从第一个开始", - "Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。", - "Record request details for inspection in the logs view": "记录请求详情以在日志视图中查看", - "Update Password": "修改密码", - "Set Password": "设置密码", - "Overview": "概览", - "Details": "详情", - "Search...": "搜索...", - "Saving...": "保存中...", - "Save": "保存", - "Save Changes": "保存更改", - "Saving": "保存中", - "Importing...": "导入中...", - "Import": "导入", - "Deploying... (may take ~1 min)": "部署中...(可能需要约 1 分钟)", - "Deploy": "部署", - "Enable": "启用", - "Disable": "禁用", - "Token Saver": "Token 节省器", - "Experimental": "实验性", - "Compress tool output to reduce token usage.": "压缩工具输出以减少 Token 使用量。", - "sk_9router (default)": "sk_9router(默认)", - "Install Tailscale": "安装 Tailscale", - "Installing Tailscale...": "正在安装 Tailscale...", - "Tailscale installed": "Tailscale 已安装", - "Tailscale Funnel": "Tailscale Funnel", - "Allow dashboard access via tunnel": "允许通过隧道访问仪表盘", - "Disconnected from server": "与服务器断开连接", - "Attempting to reconnect...": "正在尝试重新连接...", - "Click to retry": "点击重试", - "Failed to load changelog:": "加载更新日志失败:", - "Copied!": "已复制!", - "Manage reusable per-connection proxies and bind them to provider connections.": "管理可复用的连接代理并绑定到提供商连接。", - "Vercel Relay": "Vercel Relay", - "Batch Import": "批量导入", - "Add Proxy Pool": "添加代理池", - "No proxy pool entries yet": "暂无代理池条目", - "Create a proxy pool entry, then assign it to connections.": "创建代理池条目,然后分配到连接。", - "Batch Import Proxies": "批量导入代理", - "Paste Proxy List (One per line)": "粘贴代理列表(每行一个)", - "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "支持的格式:protocol://user:pass@host:port, host:port:user:pass", - "Deploy Vercel Relay": "部署 Vercel Relay", - "What is Vercel Relay?": "什么是 Vercel Relay?", - "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "将边缘中继函数部署到 Vercel,通过 Vercel 的网络代理请求。", - "Vercel API Token": "Vercel API Token", - "Project Name": "项目名称", - "Edit Proxy Pool": "编辑代理池", - "Strict Proxy": "严格代理", - "Fail request if proxy is unreachable instead of falling back to direct.": "当代理不可达时直接失败,而不是回退到直连。", - "Inactive pools are ignored by runtime resolution.": "未激活的代理池将被运行时解析忽略。", + "account has been connected.": "账号已连接。", "active": "活跃", - "inactive": "未激活", - "unknown": "未知", + "add OpenAI/Anthropic compatible endpoints": "添加 OpenAI/Anthropic 兼容端点", + "added)": "已添加)", + "again after install.": "安装后再次运行。", + "and click": "并点击", + "apiKey": "apiKey", + "below.": "下方。", "bound": "已绑定", - "Last tested:": "上次测试:", - "No proxy:": "无代理:", - "Proxy pool updated": "代理池已更新", - "Proxy pool created": "代理池已创建", - "Proxy pool deleted": "代理池已删除", - "Proxy test passed": "代理测试通过", - "Proxy test failed": "代理测试失败", - "Replay request flow — matches log files": "重放请求流程——匹配日志文件", - "Client Request": "客户端请求", - "Source Body": "源请求体", - "OpenAI Intermediate": "OpenAI 中间格式", - "Target Request": "目标请求", - "Provider Response": "提供商响应", - "OpenAI Response": "OpenAI 响应", - "Client Response": "客户端响应", - "Format": "格式化", - "Send": "发送", - "→ OpenAI": "→ OpenAI", - "→ Target": "→ 目标", - "Terminal": "终端", - "Full shell access": "完整 Shell 访问", - "Desktop": "桌面", - "Screen sharing": "屏幕共享", - "Files": "文件", - "Browse & edit files": "浏览和编辑文件", - "Scan QR to connect instantly": "扫描二维码即刻连接", - "No port forwarding needed": "无需端口转发", - "Works on any device": "适用于任何设备", - "Access your terminal, desktop & files from anywhere": "从任何地方访问您的终端、桌面和文件", - "Get 9Remote": "获取 9Remote", - "Manual configuration is still available if 9router is deployed on a remote server.": "如果 9router 部署在远程服务器上,仍可使用手动配置。", - "How to Install": "如何安装", - "Hide": "隐藏", - "Filter naming": "过滤命名", - "Filter naming requests": "过滤命名请求", - "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "拦截 Claude Code 的主题命名请求并在本地返回伪响应,节省 API Token。", - "Settings applied successfully!": "设置已成功应用!", - "Failed to apply settings": "应用设置失败", - "Settings reset successfully!": "设置已成功重置!", - "Failed to reset settings": "重置设置失败", - "No API keys - Create one in Keys page": "暂无 API 密钥 - 请在密钥页面创建", - "Subagent Model": "子代理模型", - "Select Subagent Model for Codex": "选择 Codex 子代理模型", - "Select Subagent Model for OpenCode": "选择 OpenCode 子代理模型", - "No models selected": "未选择模型", - "Click a model to set/clear active": "点击模型以设置/取消活跃状态", - "Select models to add": "选择要添加的模型", - "Add Model for OpenCode": "为 OpenCode 添加模型", - "Default Model": "默认模型", - "9Router Base URL": "9Router 基础 URL", - "Trust Cert": "信任证书", - "Trusted": "已信任", - "not detected locally": "未在本地检测到", - "Select to pre-fill, then edit model ID in the input": "选择以预填充,然后在输入框中编辑模型 ID", - "Free & Free Tier Providers": "免费及免费额度提供商", - "Testing...": "测试中...", - "Test All": "全部测试", - "Ready": "就绪", - "Valid": "有效", - "Invalid": "无效", - "Checking...": "检查中...", - "Check": "检查", - "Creating...": "创建中...", - "Network error": "网络错误", - "Provider test failed": "提供商测试失败", - "Enable provider": "启用提供商", - "Disable provider": "禁用提供商", - "Chat": "对话", - "Responses": "响应", - "passed": "通过", - "failed": "失败", - "tested": "已测试", - "Required. A friendly label for this node.": "必填。为此节点设置一个友好的显示名称。", - "Required. Used as the provider prefix for model IDs.": "必填。用作模型 ID 的提供商前缀。", - "Model ID (optional)": "模型 ID(可选)", - "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "如果提供商不支持 /models 端点,请输入模型 ID 通过 chat/completions 进行验证。", - "(via inference test)": "(通过推理测试)", - "Delete this combo?": "删除此组合?", - "Name is required": "名称为必填项", - "Failed to create combo": "创建组合失败", - "Failed to update combo": "更新组合失败", - "Only letters, numbers, -, _ and . allowed": "仅允许使用字母、数字、-、_ 和 .", - "Input Cost": "输入成本", - "Output Cost": "输出成本", - "Total Cost": "总成本", - "Total Tokens": "总 Token", - "Never": "从未", - "Just now": "刚刚", - "m ago": "分钟前", - "h ago": "小时前", - "None": "无", - "disabled": "已禁用", - "OAuth Account": "OAuth 账号", - "no_proxy:": "无代理:", - "Pool:": "代理池:", - "Legacy:": "旧版:", - "Error": "错误", - "more": "更多", - "Proxy": "代理", - "No authentication required": "无需身份验证", - "This provider is ready to use.": "此提供商已准备就绪。", - "Available Models": "可用模型", - "Model not reachable": "模型不可达", - "Failed to set alias": "设置别名失败", - "Delete this connection?": "删除此连接?", - "Proxy Pool": "代理池", - "Proxy Action": "代理操作", - "Selecting None will unbind selected connections from proxy pool.": "选择「无」将解除所选连接与代理池的绑定。", - "Applying...": "应用中...", - "Select one or more connections, then click Proxy Action.": "选择一个或多个连接,然后点击代理操作。", - "All selected currently unbound": "所有选中项当前未绑定", - "Selected connections have mixed proxy bindings": "所选连接的代理绑定状态不一致", - "Anthropic Compatible Details": "Anthropic 兼容详情", - "OpenAI Compatible Details": "OpenAI 兼容详情", - "Messages API": "消息 API", - "Sticky:": "粘滞:", + "chars)": "字符)", + "cloudflare relay": "cloudflare 中继", "connection": "个连接", "connections": "个连接", - "Suggested free models (≥200k context):": "推荐的免费模型(≥200k 上下文):", - "Get API Key →": "获取 API 密钥 →", - "OAuth": "OAuth", - "Click to add, click again to remove. Changes are saved automatically.": "点击添加,再次点击删除。更改将自动保存。", - "Close": "关闭", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "深色", + "disabled": "已禁用", + "dollars per million tokens": "美元 / 百万 Token", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "例如 CwhRBWXzGAHq8TQ4Fs17", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "例如:温暖柔和的嗓音,用英式口音缓慢说话", + "e.g. claude-opus-4-5": "例如 claude-opus-4-5", + "e.g. my-model-id": "例如 my-model-id", + "e.g. tts-1-hd": "例如 tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "例如 voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "例如,生产 API、开发环境", + "every request bills all panel models + the judge": "每次请求会计费所有面板模型 + 裁判", + "export": "导出", + "failed": "失败", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 减少 60-90% 的输入 Token", + "h ago": "小时前", + "has been connected.": "已连接。", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "iFlow Cookie 身份验证", + "import": "导入", + "inactive": "未激活", + "jcode - Manual Configuration": "jcode - 手动配置", + "jcode CLI not detected locally": "未在本地检测到 jcode CLI", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode 是基于 Rust 的编程代理,具有语义记忆、多代理集群和极致性能(27.8 MB 内存,14ms 启动)。", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "浅色", + "m ago": "分钟前", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "macOS/Linux:": "macOS/Linux:", + "more": "更多", + "more providers": "更多提供商", + "ms / Total": "毫秒/总计", + "name|apiKey": "name|apiKey", + "no_proxy:": "无代理:", + "not detected locally": "未在本地检测到", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "open http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "可选的上下文,用于提高准确性", + "or VS Code extension marketplace.": "或 VS Code 扩展市场。", + "or just": "或仅", + "passed": "通过", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "并行查询所有模型,然后由裁判综合出一个答案。质量最佳但成本最高:每次请求会计费所有面板模型 + 裁判(N+1 次调用)", + "records, batches every": "记录,每批", + "requests, max": "请求数,最大", + "rotates models across requests to spread load": "在请求间轮换模型以分散负载", + "s)": ")", + "s...": "秒...", + "seconds...": "秒...", + "sends image/PDF/audio requests to a model that supports them first": "将图片/PDF/音频请求优先发送到支持的模型", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router(默认)", + "system": "跟随系统", + "tested": "已测试", + "the database.": "数据库。", + "to apply changes": "以使更改生效", + "to verify.": "来验证。", + "traffic through 9Router via MITM.": "通过 MITM 通过 9Router 的流量。", + "tries models in order (next on failure)": "按顺序尝试模型(失败时切换到下一个)", + "unknown": "未知", + "v1.0 is now live": "v1.0 现已上线", + "vercel relay": "vercel 中继", + "yet.": "。", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© 2025 9Router。保留所有权利。", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— 并行查询所有模型,然后由裁判综合出一个答案。质量最佳但成本最高:每次请求会计费所有面板模型 + 裁判(N+1 次调用)", + "— rotates models across requests to spread load": "— 在请求间轮换模型以分散负载", + "— sends image/PDF/audio requests to a model that supports them first": "— 将图片/PDF/音频请求优先发送到支持的模型", + "— tries models in order (next on failure)": "— 按顺序尝试模型(失败时切换到下一个)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ 目标", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ 启用 DNS 以编辑模型映射", + "⚠️ Local plugins run as subprocess via": "⚠️ 本地插件通过子进程运行", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM 通过本地 CA 拦截 IDE 工具(Antigravity、GitHub Copilot、Kiro)的 HTTPS 流量,将请求重定向到您的提供商。可能违反 ToS → 账户封禁风险。使用风险自负。", - "Endpoint is exposed without an API key.": "端点未设置 API 密钥即对外暴露。" + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", + "✓ Confirm Add": "✓ 确认添加", + "📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。" } diff --git a/public/i18n/literals/zh-TW.json b/public/i18n/literals/zh-TW.json index ea9183eb..f8f41a86 100644 --- a/public/i18n/literals/zh-TW.json +++ b/public/i18n/literals/zh-TW.json @@ -12,6 +12,7 @@ "Logout": "登出", "Login": "登錄", "Providers": "提供者", + "Style": "風格", "Usage": "統計", "API Key": "API 金鑰", "Connected": "已連接", @@ -176,6 +177,7 @@ "How it works:": "工作原理:", "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE 請求 → DNS 重定向到 localhost:443 → MITM 代理攔截 → 9Router → 响應到 Antigravity/Copilot", "No API keys — create one in Keys page": "沒有 API 金鑰 — 在金鑰頁面中創建一個", + "e.g. a warm, gentle voice, speaking slowly with a British accent": "例如:溫暖柔和的嗓音,用英式口音緩慢說話", "sk_9router (default)": "sk_9router(默認)", "Server started": "服務器已啟動", "Failed to start server": "啟動服務器失敗", diff --git a/public/providers/agentrouter.png b/public/providers/agentrouter.png new file mode 100644 index 00000000..6b5ca60d Binary files /dev/null and b/public/providers/agentrouter.png differ diff --git a/public/providers/api-airforce.png b/public/providers/api-airforce.png new file mode 100644 index 00000000..9fa71e6a Binary files /dev/null and b/public/providers/api-airforce.png differ diff --git a/public/providers/baidu.png b/public/providers/baidu.png new file mode 100644 index 00000000..8ad826f2 Binary files /dev/null and b/public/providers/baidu.png differ diff --git a/public/providers/bazaarlink.png b/public/providers/bazaarlink.png new file mode 100644 index 00000000..2a16fd77 Binary files /dev/null and b/public/providers/bazaarlink.png differ diff --git a/public/providers/bluesminds.png b/public/providers/bluesminds.png new file mode 100644 index 00000000..1e5ef31e Binary files /dev/null and b/public/providers/bluesminds.png differ diff --git a/public/providers/codebuddy-cn.png b/public/providers/codebuddy-cn.png index eae3f4c1..c836282f 100644 Binary files a/public/providers/codebuddy-cn.png and b/public/providers/codebuddy-cn.png differ diff --git a/public/providers/codebuddy-intl.png b/public/providers/codebuddy-intl.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/codebuddy-intl.png differ diff --git a/public/providers/commandcode.png b/public/providers/commandcode.png index ed7c8c99..3570fb00 100644 Binary files a/public/providers/commandcode.png and b/public/providers/commandcode.png differ diff --git a/public/providers/devin-cli.png b/public/providers/devin-cli.png new file mode 100644 index 00000000..1fe62d23 Binary files /dev/null and b/public/providers/devin-cli.png differ diff --git a/public/providers/featherless.png b/public/providers/featherless.png new file mode 100644 index 00000000..b918a0ff Binary files /dev/null and b/public/providers/featherless.png differ diff --git a/public/providers/gitlab.png b/public/providers/gitlab.png new file mode 100644 index 00000000..9c790b1a Binary files /dev/null and b/public/providers/gitlab.png differ diff --git a/public/providers/grok-cli.png b/public/providers/grok-cli.png new file mode 100644 index 00000000..ef9d7abc Binary files /dev/null and b/public/providers/grok-cli.png differ diff --git a/public/providers/kilo-gateway.png b/public/providers/kilo-gateway.png new file mode 100644 index 00000000..99ea60b0 Binary files /dev/null and b/public/providers/kilo-gateway.png differ diff --git a/public/providers/llm7.png b/public/providers/llm7.png new file mode 100644 index 00000000..4f2a70a0 Binary files /dev/null and b/public/providers/llm7.png differ diff --git a/public/providers/longcat.png b/public/providers/longcat.png new file mode 100644 index 00000000..e72dffb9 Binary files /dev/null and b/public/providers/longcat.png differ diff --git a/public/providers/mmf.png b/public/providers/mmf.png new file mode 100644 index 00000000..3fbdd81d Binary files /dev/null and b/public/providers/mmf.png differ diff --git a/public/providers/morph.png b/public/providers/morph.png new file mode 100644 index 00000000..82f938f3 Binary files /dev/null and b/public/providers/morph.png differ diff --git a/public/providers/novita.png b/public/providers/novita.png new file mode 100644 index 00000000..d765fc56 Binary files /dev/null and b/public/providers/novita.png differ diff --git a/public/providers/opendesign.png b/public/providers/opendesign.png new file mode 100644 index 00000000..c68169aa Binary files /dev/null and b/public/providers/opendesign.png differ diff --git a/public/providers/perplexity-agent.png b/public/providers/perplexity-agent.png new file mode 100644 index 00000000..0b5851e5 Binary files /dev/null and b/public/providers/perplexity-agent.png differ diff --git a/public/providers/poolside.png b/public/providers/poolside.png new file mode 100644 index 00000000..592d604e Binary files /dev/null and b/public/providers/poolside.png differ diff --git a/public/providers/qoder.png b/public/providers/qoder.png index 41e81c1d..be87ec61 100644 Binary files a/public/providers/qoder.png and b/public/providers/qoder.png differ diff --git a/public/providers/reka.png b/public/providers/reka.png new file mode 100644 index 00000000..00016257 Binary files /dev/null and b/public/providers/reka.png differ diff --git a/public/providers/sambanova.png b/public/providers/sambanova.png new file mode 100644 index 00000000..38b016c7 Binary files /dev/null and b/public/providers/sambanova.png differ diff --git a/public/providers/tencent.png b/public/providers/tencent.png new file mode 100644 index 00000000..6293ff7d Binary files /dev/null and b/public/providers/tencent.png differ diff --git a/public/providers/tokenrouter.png b/public/providers/tokenrouter.png new file mode 100644 index 00000000..58f7eac2 Binary files /dev/null and b/public/providers/tokenrouter.png differ diff --git a/public/providers/trae.png b/public/providers/trae.png new file mode 100644 index 00000000..c056daf0 Binary files /dev/null and b/public/providers/trae.png differ diff --git a/public/providers/venice.png b/public/providers/venice.png new file mode 100644 index 00000000..38c528b5 Binary files /dev/null and b/public/providers/venice.png differ diff --git a/public/providers/vercel-ai-gateway.png b/public/providers/vercel-ai-gateway.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel-ai-gateway.png differ diff --git a/public/providers/vercel.png b/public/providers/vercel.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel.png differ diff --git a/public/providers/windsurf.png b/public/providers/windsurf.png new file mode 100644 index 00000000..c97179a2 Binary files /dev/null and b/public/providers/windsurf.png differ diff --git a/public/providers/workbuddy.png b/public/providers/workbuddy.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/workbuddy.png differ diff --git a/public/providers/zed.png b/public/providers/zed.png new file mode 100644 index 00000000..009c3e9d Binary files /dev/null and b/public/providers/zed.png differ diff --git a/scripts/copy-standalone-assets.mjs b/scripts/copy-standalone-assets.mjs new file mode 100644 index 00000000..c0bc0a5a --- /dev/null +++ b/scripts/copy-standalone-assets.mjs @@ -0,0 +1,44 @@ +import { cpSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = process.env.NEXT_DIST_DIR || ".next" } = {}) { + if (process.env.NEXT_TRACING_ROOT_MODE === "workspace") { + console.log("[standalone-assets] Skipping workspace-traced CLI build; CLI packaging handles assets"); + return; + } + + const buildDir = resolve(projectRoot, distDir); + const standaloneDir = resolve(buildDir, "standalone"); + + if (!existsSync(standaloneDir)) { + console.log(`[standalone-assets] No standalone build found at ${standaloneDir}`); + return; + } + + const staticSource = resolve(buildDir, "static"); + const staticDestination = resolve(standaloneDir, distDir, "static"); + if (existsSync(staticSource)) { + cpSync(staticSource, staticDestination, { recursive: true, force: true }); + console.log(`[standalone-assets] Copied static assets to ${staticDestination}`); + } + + const publicSource = resolve(projectRoot, "public"); + const publicDestination = resolve(standaloneDir, "public"); + if (existsSync(publicSource)) { + cpSync(publicSource, publicDestination, { recursive: true, force: true }); + console.log(`[standalone-assets] Copied public assets to ${publicDestination}`); + } + + // Without it beside server.js the standalone build serves requests unsanitized. + const serverWrapperSource = resolve(projectRoot, "custom-server.js"); + const serverWrapperDestination = resolve(standaloneDir, "custom-server.js"); + if (existsSync(serverWrapperSource)) { + cpSync(serverWrapperSource, serverWrapperDestination, { force: true }); + console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) { + copyStandaloneAssets(); +} diff --git a/skills/9router-video/SKILL.md b/skills/9router-video/SKILL.md new file mode 100644 index 00000000..37ea13b5 --- /dev/null +++ b/skills/9router-video/SKILL.md @@ -0,0 +1,76 @@ +--- +name: 9router-video +description: Generate videos via 9Router /v1/videos/generations using xAI Grok Imagine (grok-imagine-video). Async job flow - submit, poll request_id until done, download MP4. Use when the user wants to create, generate, or render a video, text-to-video (txt2vid), or image-to-video. +--- + +# 9Router — Video Generation (xAI Grok Imagine) + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +Requires a connected **xAI account** in the 9Router dashboard — either **Grok Build OAuth** (SuperGrok / X Premium+ subscription sign-in) or a direct **xAI API key** from console.x.ai. The two are separate auth types with separate billing; the dashboard shows which one each connection uses. + +## Endpoints (async job flow) + +Video generation is **asynchronous**: the POST returns a `request_id` immediately, then you poll until the job is `done` or `failed`. + +| Endpoint | Purpose | +|---|---| +| `POST /v1/videos/generations` | text-to-video / image-to-video | +| `POST /v1/videos/edits` | edit an existing video | +| `POST /v1/videos/extensions` | extend an existing video | +| `GET /v1/videos/{request_id}` | poll job status | + +Request fields (passed through to xAI unchanged — see https://docs.x.ai/developers/rest-api-reference/inference/videos): + +| Field | Required | Notes | +|---|---|---| +| `model` | no | `xai/grok-imagine-video` (prefix is stripped before upstream) | +| `prompt` | yes for T2V | video description | +| `duration` | no | seconds | +| `aspect_ratio` | no | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` | +| `resolution` | no | `480p`, `720p`, `1080p` | +| `image` | no | `{ "url": "https://… or data:image/…;base64,…" }` for image-to-video | +| `video` | edits/extensions | `{ "url": "…mp4" }` or `{ "file_id": "…" }` | + +## Examples + +Submit a job: + +```bash +curl -X POST "$NINEROUTER_URL/v1/videos/generations" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"xai/grok-imagine-video","prompt":"A cinematic tracking shot through a neon city at night","duration":8,"aspect_ratio":"16:9","resolution":"720p"}' +# → {"request_id":"abc123"} (response header x-9router-connection-id: ) +``` + +Poll until done (echo the connection header back so the same account polls the job): + +```bash +curl "$NINEROUTER_URL/v1/videos/abc123" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "x-connection-id: " +# → {"status":"pending","progress":42} +# → {"status":"done","video":{"url":"https://…mp4","duration":8},"model":"grok-imagine-video"} +# → {"status":"failed","error":{"code":"…","message":"…"}} +``` + +Download: fetch `video.url` from the `done` response. + +## CLI one-shot + +```bash +9router xai video \ + --prompt "A cinematic tracking shot through a neon city at night" \ + --output video.mp4 +# options: --model --duration --aspect-ratio --resolution --image --timeout --port --api-key +``` + +Submits, polls with progress, downloads to `video.mp4.part`, atomically renames on success. Ctrl+C cancels cleanly; non-zero exit on failure. + +## Notes & limits + +- Jobs are **account-bound** upstream: poll with the same connection that created the job (`x-connection-id` header, value from the create response's `x-9router-connection-id`). +- Creation POSTs are **never auto-retried** (a retry could create and bill two videos). Only a 401→token-refresh→single-retry is performed, which upstream rejects before job creation. +- Video models are tagged `kind: "video"` and are excluded from chat model lists and chat fallback combos. +- Grok Build **subscription OAuth** tokens are sent to the same `api.x.ai/v1/videos` endpoints as API keys; whether a given subscription tier includes video-generation quota is controlled by xAI and is not verified by 9Router — a `403`/`permission_denied` from upstream means the connected account has no video access. diff --git a/skills/README.md b/skills/README.md index f9f06b90..f3c0062e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -11,6 +11,7 @@ Drop-in skills for any AI agent (Claude, Cursor, ChatGPT, custom SDK). Just **co | **Entry / Setup** (start here) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md | | Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md | | Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md | +| Video generation (xAI Grok Imagine) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-video/SKILL.md | | Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md | | Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md | | Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md | diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index a97d0a5e..f9331074 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -891,7 +891,7 @@ export default function BasicChatPageClient() {
{message.attachments.map((attachment) => ( - {attachment.name} + {attachment.name} ))}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index fa8d7111..209a6d33 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -9,7 +9,7 @@ import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, - JcodeToolCard, + JcodeToolCard, GrokBuildToolCard, } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -81,6 +81,33 @@ export default function ToolDetailClient({ toolId, machineId }) { models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id }); } }); + + // openai/anthropic-compatible providers are registered with a random UUID (e.g. + // "openai-compatible-chat-") that has no entry in the static PROVIDER_MODELS + // catalog, so `getModelsByProviderId` returns []. Routing still works because the + // request path uses the connection's own model config, but `hasActiveProviders` + // below would flip to false and disable the Apply button. Fall back to the + // connection's own models so these providers are usable from CLI tool pages. + if (providerModels.length === 0) { + const prefix = conn.providerSpecificData?.prefix || alias; + const fallbackModels = []; + if (conn.defaultModel) fallbackModels.push({ id: conn.defaultModel, name: conn.defaultModel }); + (conn.providerSpecificData?.customModels || []).forEach(m => { + if (m?.id && !fallbackModels.some(f => f.id === m.id)) fallbackModels.push({ id: m.id, name: m.name || m.id }); + }); + if (fallbackModels.length === 0 && conn.testStatus === "active") { + // Provider is confirmed reachable but exposes no model info anywhere; + // still let the user apply so they aren't stuck on a permanently disabled button. + fallbackModels.push({ id: "model-id", name: `${prefix}/model-id` }); + } + fallbackModels.forEach(m => { + const modelValue = `${prefix}/${m.id}`; + if (!seenModels.has(modelValue)) { + seenModels.add(modelValue); + models.push({ value: modelValue, label: `${prefix}/${m.id}`, provider: conn.provider, alias: prefix, connectionName: conn.name, modelId: m.id }); + } + }); + } }); return models; }; @@ -139,6 +166,8 @@ export default function ToolDetailClient({ toolId, machineId }) { return ; case "jcode": return ; + case "grok-build": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index 48962cca..6a7339d3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -38,15 +38,10 @@ export default function AntigravityToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !status) { - fetchStatus(); - loadSavedMappings(); - fetchModelAliases(); - } - if (isExpanded) { - loadSavedMappings(); - fetchModelAliases(); - } + if (!isExpanded) return; + if (!status) fetchStatus(); + loadSavedMappings(); + fetchModelAliases(); }, [isExpanded]); const loadSavedMappings = async () => { @@ -243,6 +238,8 @@ export default function AntigravityToolCard({ className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({ {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 589d847a..7ee5ad2e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -9,6 +9,16 @@ import { matchKnownEndpoint } from "./cliEndpointMatch"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +// Context window presets. UI shows the round number; the value written is nudged +// down 2K to stay safely under the upstream hard cap. +const CONTEXT_OPTIONS = [ + { label: "Default", value: "" }, + { label: "200K", value: "198000" }, + { label: "300K", value: "298000" }, + { label: "500K", value: "498000" }, + { label: "1M", value: "998000" }, +]; + export default function ClaudeToolCard({ tool, isExpanded, @@ -39,6 +49,8 @@ export default function ClaudeToolCard({ const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [ccFilterNaming, setCcFilterNaming] = useState(false); + const [exaMcpEnabled, setExaMcpEnabled] = useState(false); + const [maxContextTokens, setMaxContextTokens] = useState(""); const hasInitializedModels = useRef(false); const getConfigStatus = () => { @@ -58,15 +70,22 @@ export default function ClaudeToolCard({ }, [apiKeys, selectedApiKey]); useEffect(() => { - if (initialStatus) setClaudeStatus(initialStatus); + if (initialStatus) { + setClaudeStatus(initialStatus); + setExaMcpEnabled(!!initialStatus.exaMcpEnabled); + } }, [initialStatus]); useEffect(() => { - if (isExpanded && !claudeStatus) { - checkClaudeStatus(); + const v = claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS; + setMaxContextTokens(v || ""); + }, [claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS]); + + useEffect(() => { + if (isExpanded) { + if (!claudeStatus) checkClaudeStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); useEffect(() => { @@ -123,6 +142,7 @@ export default function ClaudeToolCard({ const res = await fetch("/api/cli-tools/claude-settings"); const data = await res.json(); setClaudeStatus(data); + setExaMcpEnabled(!!data.exaMcpEnabled); } catch (error) { setClaudeStatus({ installed: false, error: error.message }); } finally { @@ -159,15 +179,18 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); + if (maxContextTokens) { + env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + } const res = await fetch("/api/cli-tools/claude-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env }), + body: JSON.stringify({ env, exaMcpEnabled, maxContextTokens }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: "Settings applied successfully!" }); - setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } })); + setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); } else { setMessage({ type: "error", text: data.error || "Failed to apply settings" }); } @@ -188,6 +211,8 @@ export default function ClaudeToolCard({ setMessage({ type: "success", text: "Settings reset successfully!" }); tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); setSelectedApiKey(""); + setExaMcpEnabled(false); + setMaxContextTokens(""); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); } @@ -217,6 +242,9 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); + if (maxContextTokens) { + env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + } return [ { @@ -231,7 +259,7 @@ export default function ClaudeToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -340,6 +368,17 @@ export default function ClaudeToolCard({
))} + {/* Context Window */} +
+ Context window + arrow_forward + +
+ {/* CC Filter Naming */}
Filter naming @@ -352,6 +391,19 @@ export default function ClaudeToolCard({
+ + {/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */} +
+ Web Search + arrow_forward + +
{message && ( @@ -377,7 +429,9 @@ export default function ClaudeToolCard({
)} - setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> + {modalOpen && ( + setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); useEffect(() => { @@ -157,7 +156,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -280,15 +279,17 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Cline" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Cline" + /> + )} { - if (isExpanded && !codexStatus) { - checkCodexStatus(); + if (isExpanded) { + if (!codexStatus) checkCodexStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -199,7 +198,7 @@ model = "${effectiveSubagentModel}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Codex" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for Codex" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for Codex" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); // Pre-fill from existing config @@ -184,7 +183,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -290,27 +289,29 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
)} - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - } - }} - onDeselect={(model) => { - setSelectedModels(selectedModels.filter(m => m !== model.value)); - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for GitHub Copilot" - /> + {modalOpen && ( + { + setModalOpen(false); + saveModels(selectedModelsRef.current); + }} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + } + }} + onDeselect={(model) => { + setSelectedModels(selectedModels.filter(m => m !== model.value)); + }} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + addedModelValues={selectedModels} + closeOnSelect={false} + title="Add Model for GitHub Copilot" + /> + )}
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -514,27 +514,31 @@ export default function CoworkToolCard({ configs={getManualConfigs()} /> - setComboModalOpen(false)} - onSave={handleCreateCombo} - activeProviders={activeProviders} - forcePrefix="claude-" - title="Create Cowork Combo" - /> + {comboModalOpen && ( + setComboModalOpen(false)} + onSave={handleCreateCombo} + activeProviders={activeProviders} + forcePrefix="claude-" + title="Create Cowork Combo" + /> + )} - setModelSelectOpen(false)} - onSelect={handleAddModel} - onDeselect={handleRemoveModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Cowork Model" - addedModelValues={selectedModels} - closeOnSelect={false} - /> + {modelSelectOpen && ( + setModelSelectOpen(false)} + onSelect={handleAddModel} + onDeselect={handleRemoveModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Cowork Model" + addedModelValues={selectedModels} + closeOnSelect={false} + /> + )} { - if (isExpanded && !deepseekStatus) { - checkStatus(); + if (isExpanded) { + if (!deepseekStatus) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -187,7 +186,7 @@ model = "${selectedModel || "provider/model-id"}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for DeepSeek TUI" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for DeepSeek TUI" + /> + )} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" /> ); } if (tool.icon) { return {tool.icon}; } + const iconSrc = getProviderIconSrc(toolId); + if (!iconSrc) { + return {(toolId || "?").slice(0, 2).toUpperCase()}; + } return ( {tool.name} { e.target.style.display = "none"; }} + onError={(e) => { + markProviderIconMissing(toolId); + e.target.style.display = "none"; + }} + loading="lazy" + decoding="async" /> ); }; @@ -257,14 +269,16 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
)} - setShowModelModal(false)} - onSelect={handleSelectModel} - selectedModel={modelValue} - activeProviders={activeProviders} - title="Select Model" - /> + {showModelModal && ( + setShowModelModal(false)} + onSelect={handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title="Select Model" + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 7e8e2eac..adc2a7ae 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -60,11 +60,10 @@ export default function DroidToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !droidStatus) { - checkDroidStatus(); + if (isExpanded) { + if (!droidStatus) checkDroidStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -225,7 +224,7 @@ export default function DroidToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -389,15 +388,17 @@ export default function DroidToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Factory Droid" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Factory Droid" + /> + )} +
+ {label} + {help &&

{help}

} +
+ arrow_forward +
+ onChange(event.target.value)} + placeholder={placeholder} + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {value && ( + + )} +
+ +
+ ); +} + +export default function GrokBuildToolCard({ + tool, + isExpanded, + onToggle, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const { getCaps } = useModelCaps(); + const getContextWindow = (model) => getCaps(model)?.contextWindow || null; + const initialModel = initialStatus?.settings?.model?.model || ""; + const initialSubagents = Object.fromEntries( + SUBAGENT_TYPES + .map((type) => [type.id, initialStatus?.settings?.subagentModels?.[type.id]?.model]) + .filter(([, model]) => Boolean(model)), + ); + const [grokStatus, setGrokStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(apiKeys?.[0]?.key || ""); + const [selectedModel, setSelectedModel] = useState(initialModel); + const [subagentModels, setSubagentModels] = useState(initialSubagents); + const [modelTarget, setModelTarget] = useState(null); // "main" or subagent type + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasFetchedStatus = useRef(Boolean(initialStatus)); + + const configuredModel = grokStatus?.settings?.model; + const configStatus = !grokStatus?.installed + ? null + : !configuredModel?.base_url + ? "not_configured" + : matchKnownEndpoint(configuredModel.base_url, { tunnelPublicUrl, tailscaleUrl }) + ? "configured" + : "other"; + + const hydrateForm = useCallback((status) => { + const mainModel = status?.settings?.model?.model || ""; + const configuredSubagents = Object.fromEntries( + SUBAGENT_TYPES + .map((type) => [type.id, status?.settings?.subagentModels?.[type.id]?.model]) + .filter(([, model]) => Boolean(model)), + ); + setSelectedModel(mainModel); + setSubagentModels(configuredSubagents); + }, []); + + const fetchModelAliases = useCallback(async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }, []); + + const checkStatus = useCallback(async ({ hydrate = false } = {}) => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const status = await res.json(); + setGrokStatus(status); + hasFetchedStatus.current = true; + if (hydrate) hydrateForm(status); + } catch (error) { + setGrokStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }, [hydrateForm]); + + useEffect(() => { + if (!isExpanded) return; + let cancelled = false; + const synchronize = async () => { + if (!hasFetchedStatus.current) await checkStatus({ hydrate: true }); + if (!cancelled) await fetchModelAliases(); + }; + synchronize(); + return () => { cancelled = true; }; + }, [isExpanded, checkStatus, fetchModelAliases]); + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || (typeof window !== "undefined" + ? window.location.origin.replace("://localhost", "://127.0.0.1") + : "http://127.0.0.1:20128"); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + const mappedSubagents = {}; + for (const type of SUBAGENT_TYPES) { + const model = subagentModels[type.id]?.trim(); + if (model) mappedSubagents[type.id] = { model, contextWindow: getContextWindow(model) }; + } + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + contextWindow: getContextWindow(selectedModel), + subagentModels: mappedSubagents, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Main and subagent models applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSubagentModels({}); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + if (modelTarget === "main") { + setSelectedModel(model.value); + } else if (modelTarget) { + setSubagentModels((current) => ({ ...current, [modelTarget]: model.value })); + } + setModelTarget(null); + }; + + const getManualConfigs = () => { + const keyToUse = selectedApiKey?.trim() + || (!cloudEnabled ? "sk_9router" : ""); + const baseUrl = getEffectiveBaseUrl(); + const mainModel = selectedModel || "provider/model-id"; + const blocks = [ + `[models]\ndefault = "${MODEL_SLOT}"`, + `[model.${MODEL_SLOT}]\nmodel = "${mainModel}"\nbase_url = "${baseUrl}"\nname = "9Router"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(mainModel) || 200000}`, + ]; + const mappings = []; + for (const type of SUBAGENT_TYPES) { + const model = subagentModels[type.id]?.trim(); + if (!model) continue; + const slot = `${MODEL_SLOT}-${type.id}`; + mappings.push(`${type.id} = "${slot}"`); + blocks.push(`[model.${slot}]\nmodel = "${model}"\nbase_url = "${baseUrl}"\nname = "9Router ${type.id}"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(model) || 200000}`); + } + if (mappings.length) blocks.splice(1, 0, `[subagents.models]\n${mappings.join("\n")}`); + return [{ filename: "~/.grok/config.toml", content: `${blocks.join("\n\n")}\n` }]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking &&
progress_activityChecking Grok Build...
} + + {!checking && grokStatus && !grokStatus.installed && ( +
+
+ warning +
+

Grok Build not detected locally

+ curl -fsSL https://x.ai/cli/install.sh | bash +
+
+ +
+ )} + + {!checking && grokStatus?.installed && ( + <> +
+ {tool.notes?.length > 0 && ( +
+ {tool.notes.map((note, index) => ( +
+ {note.type === "warning" ? "warning" : "info"} + {note.text} +
+ ))} +
+ )} +
+ Select Endpoint + arrow_forward + +
+ + {configuredModel?.base_url && ( +
+ Current + arrow_forward + {configuredModel.base_url} · {configuredModel.model}{configuredModel.context_window ? ` · ${(configuredModel.context_window / 1000).toLocaleString()}K ctx` : ""} +
+ )} + +
+ API Key + arrow_forward + +
+ + setModelTarget("main")} disabled={!hasActiveProviders} /> + +
+
+ account_tree +
+

Subagent model overrides

+

Leave blank to inherit Main Model. Each override keeps its own context window.

+
+
+
+ + {SUBAGENT_TYPES.map((type) => ( + setSubagentModels((current) => ({ ...current, [type.id]: value }))} + placeholder={`${selectedModel || "Main Model"} (inherit)`} + onSelect={() => setModelTarget(type.id)} + disabled={!hasActiveProviders} + /> + ))} +
+ + {message &&
{message.type === "success" ? "check_circle" : "error"}{message.text}
} + +
+ + + +
+ + )} +
+ )} + + {modelTarget && ( + setModelTarget(null)} + onSelect={handleModelSelect} + selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={modelTarget === "main" ? "Select Main Model for Grok Build" : `Select ${SUBAGENT_TYPES.find((type) => type.id === modelTarget)?.label || "Subagent"} Model`} + /> + )} + + setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 806be6bb..9ef6cddf 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -58,11 +58,10 @@ export default function HermesToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !hermesStatus) { - checkStatus(); + if (isExpanded) { + if (!hermesStatus) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -171,7 +170,7 @@ export default function HermesToolCard({ ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); - const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`; + const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`; const envContent = `OPENAI_API_KEY=${keyToUse}\n`; return [ @@ -185,7 +184,7 @@ export default function HermesToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -296,15 +295,17 @@ export default function HermesToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Hermes Agent" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Hermes Agent" + /> + )} { - if (isExpanded && !jcodeStatus) { - checkJcodeStatus(); + if (isExpanded) { + if (!jcodeStatus) checkJcodeStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -215,7 +214,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for jcode" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for jcode" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -144,7 +143,7 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -254,15 +253,17 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Kilo Code" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Kilo Code" + /> + )} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index 82384292..3ca51d2e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -143,6 +143,8 @@ export default function MitmToolCard({ className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
@@ -304,15 +306,17 @@ export default function MitmToolCard({ )} {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 88a73c63..b646a6eb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -57,11 +57,10 @@ export default function OpenClawToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !openclawStatus) { - checkOpenclawStatus(); + if (isExpanded) { + if (!openclawStatus) checkOpenclawStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -233,7 +232,7 @@ export default function OpenClawToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Open Claw" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Open Claw" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); // Sync models from existing config @@ -222,7 +221,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -452,42 +451,46 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)} - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - if (!activeModel) setActiveModel(model.value); - } - }} - onDeselect={(model) => { - const remaining = selectedModels.filter(m => m !== model.value); - setSelectedModels(remaining); - if (activeModel === model.value) { - setActiveModel(remaining[0] || ""); - } - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for OpenCode" - /> + {modalOpen && ( + { + setModalOpen(false); + saveModels(selectedModelsRef.current); + }} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + if (!activeModel) setActiveModel(model.value); + } + }} + onDeselect={(model) => { + const remaining = selectedModels.filter(m => m !== model.value); + setSelectedModels(remaining); + if (activeModel === model.value) { + setActiveModel(remaining[0] || ""); + } + }} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + addedModelValues={selectedModels} + closeOnSelect={false} + title="Add Model for OpenCode" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for OpenCode" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for OpenCode" + /> + )}
{tool.image ? ( - {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" /> ) : tool.icon ? ( {tool.icon} ) : null} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index aeca8700..e1399677 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -12,6 +12,7 @@ export { default as ClineToolCard } from "./ClineToolCard"; export { default as KiloToolCard } from "./KiloToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as JcodeToolCard } from "./JcodeToolCard"; +export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index abfc215c..fa093bfd 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -5,13 +5,45 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; +import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select, Toggle } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; +// Capacity adapter: global fallback pools of models per input-modality capability. +// A request needing a capability the target model/combo lacks switches straight +// to the first enabled model here instead of erroring or dropping the data. +const CAPACITY_ADAPTER_CAPS = [ + { key: "vision", label: "Vision", icon: "visibility", desc: "Images" }, + // pdf, videoInput temporarily hidden — no translator support yet for those blocks. + { key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "Audio input" }, +]; +const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free"; +const EMPTY_CAP_ENTRY = { enabled: true, roundRobin: false, models: [] }; +const EMPTY_CAPACITY_ADAPTER = { + vision: { ...EMPTY_CAP_ENTRY }, + pdf: { ...EMPTY_CAP_ENTRY }, + audioInput: { ...EMPTY_CAP_ENTRY }, + videoInput: { ...EMPTY_CAP_ENTRY }, +}; +// Backward-compat: legacy stored form was an array of {model, enabled}. +function normalizeCapEntry(entry) { + if (Array.isArray(entry)) { + return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) }; + } + if (entry && typeof entry === "object") { + return { + enabled: entry.enabled !== false, + roundRobin: !!entry.roundRobin, + models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [], + }; + } + return { ...EMPTY_CAP_ENTRY }; +} + export default function CombosPage() { const [combos, setCombos] = useState([]); const [loading, setLoading] = useState(true); @@ -19,7 +51,8 @@ export default function CombosPage() { const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); - const [modelCaps, setModelCaps] = useState({}); + const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER); + const { getCaps } = useModelCaps(); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); @@ -29,11 +62,10 @@ export default function CombosPage() { const fetchData = async () => { try { - const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ + const [combosRes, providersRes, settingsRes] = await Promise.all([ fetch("/api/combos"), fetch("/api/providers"), fetch("/api/settings"), - fetch("/api/models"), ]); const combosData = await combosRes.json(); const providersData = await providersRes.json(); @@ -44,14 +76,13 @@ export default function CombosPage() { if (providersRes.ok) { setActiveProviders(providersData.connections || []); } - if (modelsRes.ok) { - const md = await modelsRes.json(); - // Build fullModel -> caps map for badge lookup - const map = {}; - for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps; - setModelCaps(map); - } setComboStrategies(settingsData.comboStrategies || {}); + const rawAdapter = settingsData.capacityAdapter || {}; + const normalized = {}; + for (const cap of CAPACITY_ADAPTER_CAPS) { + normalized[cap.key] = normalizeCapEntry(rawAdapter[cap.key]); + } + setCapacityAdapter(normalized); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -59,6 +90,19 @@ export default function CombosPage() { } }; + const handleSetCapacityAdapter = async (next) => { + setCapacityAdapter(next); + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ capacityAdapter: next }), + }); + } catch (error) { + console.log("Error updating capacity adapter:", error); + } + }; + const handleCreate = async (data) => { try { const res = await fetch("/api/combos", { @@ -161,7 +205,6 @@ export default function CombosPage() {
  • Fallback — tries models in order (next on failure)
  • Round Robin — rotates models across requests to spread load
  • Fusion — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)
  • -
  • Capacity auto-switch — sends image/PDF/audio requests to a model that supports them first
  • {/* Judge model picker (single-select; combo members make natural judges too) */} - setShowJudgeSelect(false)} - onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - activeProviders={activeProviders} - title="Select Judge Model" - addedModelValues={judge ? [judge] : []} - closeOnSelect={true} - /> + {showJudgeSelect && ( + setShowJudgeSelect(false)} + onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} + activeProviders={activeProviders} + title="Select Judge Model" + addedModelValues={judge ? [judge] : []} + closeOnSelect={true} + /> + )} + + ); +} + +function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, getCaps }) { + return ( +
    +
    +
    +

    Vision Adapter

    +

    + Your model can't read image/audio? Auto-switches to a model in the pool below. +

    +
      +
    • Vision — images (png, jpg, webp, …)
    • +
    • Audio — audio input
    • +
    +
    +
    +
    + {CAPACITY_ADAPTER_CAPS.map((cap) => ( + onChange({ ...capacityAdapter, [cap.key]: entry })} + activeProviders={activeProviders} + getCaps={getCaps} + /> + ))} +
    +
    + ); +} + +function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps }) { + const [showModelSelect, setShowModelSelect] = useState(false); + const { enabled, roundRobin, models } = entry; + + const patch = (p) => onChange({ ...entry, ...p }); + + const handleAdd = (model) => { + if (models.includes(model.value)) return; + patch({ models: [...models, model.value] }); + }; + + const handleRemove = (index) => { + const next = models.filter((_, i) => i !== index); + patch({ models: next.length === 0 ? [DEFAULT_FALLBACK_MODEL] : next }); + }; + + const handleMove = (index, delta) => { + const target = index + delta; + if (target < 0 || target >= models.length) return; + const next = [...models]; + [next[index], next[target]] = [next[target], next[index]]; + patch({ models: next }); + }; + + return ( + +
    + {/* Master toggle + icon + label + chips */} +
    + patch({ enabled: v })} + aria-label={`Enable ${cap.label} adapter`} + /> +
    + {cap.icon} +
    +
    +
    + {cap.label} + — {cap.desc} +
    +
    + {models.length === 0 ? ( + No models + ) : ( + models.slice(0, 3).map((model, index) => ( + + {model} + + + + + + )) + )} + {models.length > 3 && ( + +{models.length - 3} more + )} +
    +
    +
    + + {/* Actions: Round-robin toggle + Add Model */} +
    + + +
    +
    + + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAdd} + activeProviders={activeProviders} + title={`Add ${cap.label} Model`} + addedModelValues={models} + capFilter={cap.key} + closeOnSelect={false} + /> + )}
    ); } @@ -637,18 +836,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF {/* Model Select Modal */} - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model to Combo" + kindFilter={kindFilter} + addedModelValues={models} + closeOnSelect={false} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index a7183741..b6bfd2e4 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -255,11 +255,26 @@ export default function APIPageClient({ machineId }) { const fetchData = async () => { try { - const keysRes = await fetch("/api/keys"); - const keysData = await keysRes.json(); - if (keysRes.ok) { - setKeys(keysData.keys || []); + const fetchKeys = async () => { + const res = await fetch("/api/keys"); + if (!res.ok) return []; + const data = await res.json(); + return data.keys || []; + }; + + let existing = await fetchKeys(); + // Auto-provision a default key for first-time users so the endpoint works out of the box. + if (existing.length === 0) { + try { + const createRes = await fetch("/api/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Default Key" }), + }); + if (createRes.ok) existing = await fetchKeys(); + } catch { /* fall through to empty render */ } } + setKeys(existing); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -1005,7 +1020,7 @@ export default function APIPageClient({ machineId }) {
    @@ -383,6 +385,8 @@ export function GenericExampleCard({ providerId, kind }) { className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" onError={(e) => { e.currentTarget.style.display = "none"; }} onLoad={(e) => { e.currentTarget.style.display = "block"; }} + loading="lazy" + decoding="async" /> )}
    @@ -487,6 +491,8 @@ export function GenericExampleCard({ providerId, kind }) { src={`data:image/png;base64,${partialImage.b64_json}`} alt="Partial" className="max-w-full rounded-lg border border-border mt-1.5 opacity-80" + loading="lazy" + decoding="async" />
    )} @@ -529,6 +535,8 @@ export function GenericExampleCard({ providerId, kind }) { src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)} alt="Generated" className="max-w-full rounded-lg border border-border" + loading="lazy" + decoding="async" />
    )} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js index e0191903..a3fb1d32 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js @@ -6,6 +6,7 @@ import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; +import { translate } from "@/i18n/runtime"; import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js"; import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js"; import { Row } from "./exampleShared"; @@ -40,6 +41,7 @@ export function TtsExampleCard({ providerId }) { // Form state const [input, setInput] = useState("Hello, this is a text to speech test."); + const [style, setStyle] = useState(""); // style/voice instructions (e.g. MiMo voicedesign) const [apiKey, setApiKey] = useState(""); const [useTunnel, setUseTunnel] = useState(false); const [localEndpoint, setLocalEndpoint] = useState(""); @@ -59,8 +61,10 @@ export function TtsExampleCard({ providerId }) { const [modalSearch, setModalSearch] = useState(""); const [modalError, setModalError] = useState(""); const [byLang, setByLang] = useState({}); - // Language hint (e.g. Gemini): controls the spoken language without affecting voice selection + // Language hint (e.g. Gemini/MiMo): guides the spoken language without affecting voice selection const [languageHint, setLanguageHint] = useState(""); + // Number of stored provider connections (shown when no dashboard API key) + const [connectionCount, setConnectionCount] = useState(0); useEffect(() => { setLocalEndpoint(window.location.origin); @@ -68,6 +72,10 @@ export function TtsExampleCard({ providerId }) { .then((r) => r.json()) .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) .catch(() => {}); + fetch("/api/providers", { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => { setConnectionCount((d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false).length); }) + .catch(() => {}); fetch("/api/tunnel/status") .then((r) => r.json()) .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) @@ -111,6 +119,10 @@ export function TtsExampleCard({ providerId }) { if (voices.length) { setSelectedVoice(voices[0].id); setSelectedVoiceName(voices[0].name || voices[0].id); + } else { + // Model has no preset voices (voicedesign/voiceclone) — drop stale voice + setSelectedVoice(""); + setSelectedVoiceName(""); } }, [selectedModel]); @@ -184,6 +196,7 @@ export function TtsExampleCard({ providerId }) { const ttsBody = (() => { const b = { model: modelFull, input }; if (config.hasLanguageHint && languageHint) b.language = languageHint; + if (config.hasStyleInput && style.trim()) b.style = style.trim(); return b; })(); const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ @@ -218,7 +231,8 @@ export function TtsExampleCard({ providerId }) { if (responseFormat === "json") { const data = await res.json(); setJsonResponse(data); // Store full JSON response - const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob()); + const format = data.format || "mp3"; + const audioBlob = await fetch(`data:audio/${format};base64,${data.audio}`).then(r => r.blob()); setAudioUrl(URL.createObjectURL(audioBlob)); } else { const blob = await res.blob(); @@ -259,7 +273,11 @@ export function TtsExampleCard({ providerId }) { - {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + {apiKey + ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` + : connectionCount > 0 + ? Using stored key(s) · {connectionCount} connection{connectionCount > 1 ? "s" : ""} + : No key configured} @@ -281,7 +299,7 @@ export function TtsExampleCard({ providerId }) { )} - {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */} + {/* Language hint dropdown (Gemini, Xiaomi MiMo) — sends body.language to guide pronunciation */} {config.hasLanguageHint && ( )} @@ -320,7 +340,7 @@ export function TtsExampleCard({ providerId }) { )} - {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */} + {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs/MiMo) */} {countryVoices.length > 0 && (
    @@ -338,7 +358,9 @@ export function TtsExampleCard({ providerId }) { : "border-border text-text-muted hover:text-primary hover:border-primary/40" }`} > - {v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""} + {v.name} + {v.language ? ` · ${v.language}` : ""} + {v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""} {v.free_users_allowed === true && ( Free )} @@ -418,6 +440,30 @@ export function TtsExampleCard({ providerId }) {
    + {/* Style / voice instructions (Xiaomi MiMo) */} + {config.hasStyleInput && ( + +
    +