diff --git a/.dockerignore b/.dockerignore index 5b921cd983c..3abf6439236 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,9 @@ # VCS .git **/.git +.github +.gitignore +.npmignore # Editor .vscode @@ -8,7 +11,9 @@ # Dependencies and build output node_modules +**/node_modules .next +**/.next out build dist @@ -30,3 +35,25 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* + +# Devkit / tooling / agent scratch — never needed at build time +.claude +.devkit +.hermes +.codegraph +.codex-pentest + +# Docs / reports / examples not consumed by the build +report +docs +gitbook +images +CHANGELOG.md +README.md +README.zh-CN.md +DOCKER.md +LICENSE + +# Tests and test tooling +tests +tester diff --git a/CHANGELOG.md b/CHANGELOG.md index 54cab1d69e3..f325eb60651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +# v0.4.71 (2026-06-06) + +## Features +- Caveman: add wenyan classical Chinese levels and sync upstream prompts; locale-based visibility on endpoint page +- i18n: endpoint exposure notice across multiple languages + Russian README +- Antigravity: add gemini-3.5-flash-extra-low (Low) model +- xiaomi-tokenplan: add Claude-native MiMo V2.5 Pro alias via dedicated executor +- Qoder: fetch latest model + dashboard import-model button (#1642) +- MiniMax: add MiniMax-M3 + update Quota Tracker coding/CN (#1631) + +## Fixes +- Codex: harden streaming timeouts (stall/connect raised to 60s, configurable per-provider), accept `response.done` event, and always emit a terminal `response.failed` + `[DONE]` for Responses passthrough when a stream closes, stalls, or aborts before a terminal event — prevents codex clients from hanging (#1648, #1680, #1688, #1618) +- Codex: durable OAuth refresh lifecycle (#1664) +- Tunnel: skip virtual interfaces to prevent false netchange watchdog +- Claude: fix forced tool_choice 400 on cc/ OAuth route (#1592) +- Proxy: raise Next client body limit to 128MB via `NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE` (#1529, #1572) +- MiniMax: echo `reasoning_content` on follow-up turns to avoid 400 (#1543) +- Kiro: handle 400 on tool-bearing history without client tools; add mappable "auto" model slot; fix binary EventStream crash + add models & TTS tool filtering +- Antigravity: passthrough tab-autocomplete + mark default agent slot mandatory +- Qoder: allow `qmodel_latest` model key (#1638) +- Providers: restore one-connection guard for compatible/embedding nodes +- Model-test: route image/STT probes to their real endpoints, harden STT ping; add opencode-go + xiaomi-tokenplan to connection test (#1576, #1628) + +## Improvements +- Dashboard: reorganize menu actions across sidebar/header/profile +- Translator: add data-driven coverage, bug-exposing cases, and real provider smoke tests + # v0.4.66 (2026-05-29) ## Features @@ -192,366 +219,4 @@ - Restore /app/server.js in Docker standalone build (#1064, #1067) - Fix CLI TUI menu arrow-key escape sequences leaking (^[[A^[[B) - Switch macOS/Linux tray to systray2 fork (fixes Kaspersky AV false-positive) (#1080) -- Fix zoom controls contrast in topology view (#1066) - -# v0.4.33 (2026-05-12) - -## Improvements -- Windows: replace systray (Go binary, AV flagged) with native PowerShell NotifyIcon -- Auto-cleanup legacy `tray_windows.exe` on install/startup - -# v0.4.31 (2026-05-12) - -## Features -- OIDC dashboard login: Authentik/Keycloak/Google/Okta SSO with password-only, OIDC-only, or both modes (#1020) -- Linux/arm64 Docker image support (#979) -- Codex GPT 5.5 image support (#991) -- Done button in ModelSelectModal during combo creation (#1031) -- CLI: reset auth mode to password (emergency OIDC lockout recovery) - -## Fixes -- DATA_DIR: graceful fallback to ~/.9router on EACCES/EPERM (#1005) -- React hooks: variable declaration order & lazy initialization (#1017) - -## Improvements -- Profile page: OIDC settings card collapsed by default to reduce clutter -- Header: user pill only shown when logged in via OIDC - -# v0.4.30 (2026-05-11) - -## Features -- MCP stdio→SSE bridge: expose local stdio MCP plugins over SSE (api/mcp/[plugin]/sse, /message) -- Dynamic Linux cert resolution + NSS DB injection (Debian/Arch/Fedora/openSUSE, Chrome/Chromium/Firefox incl. snap) (#1010) -- Cowork tool: expanded settings UI & API -- GitBook docs (DocsContent, DocsLayout) - -## Fixes -- OAuth callback postMessage scoped to expected origins (CWE-1385) (#998) -- Re-enable TLS verification on DNS-bypass fetch (CWE-295) (#998) -- Normalize `developer` role → `system` for OpenAI-format providers (Deepseek, Groq, …) (#1011, closes #773) -- Respect `PORT` env in internal model-test fetch (#1014) -- Dropdown text readability in dark theme on usage page (#997) - -## Improvements -- Refactor Claude CLI spoof headers into shared constant -- Tool deduper utility in open-sse handlers - -# v0.4.29 (2026-05-10) - -## Features -- Add Cline & Kilo Code tool cards -- Tailscale TUN mode for stable Funnel TLS -- Sort APIKEY providers by usage, collapse to top 20 - -## Improvements -- Local Material Symbols font (no Google Fonts) -- Docker base: Bun → Node 22-alpine -- MITM reads aliases from JSON cache (no native sqlite) -- Stream stall timeout (3 min) in open-sse - -## Fixes -- Fal.ai key test: use stable models endpoint - -# v0.4.28 (2026-05-10) - -## Features -- Add bun:sqlite adapter with automatic runtime detection (Bun/Node) -- Add bulk API key import (format: `name|sk-key`, one per line) - -## Fixes -- Fix add API key for custom providers - -# v0.4.27 (2026-05-09) - -## Features -- Add 3-tier DB driver fallback: better-sqlite3 → node:sqlite (Node ≥22.5) → sql.js - -## Fixes -- Fix authentication logic for several providers - -# v0.4.25 (2026-05-09) - -## Features -- Add MCP Marketplace Modal to Cowork Tool Card for easier plugin management -- Migrate DB layer from lowdb to SQLite with modular repos pattern (better-sqlite3 / sql.js adapters, migrations, helpers) -- Add Tailscale tunnel integration with status check API -- Add `/api/cli-tools/all-statuses` aggregated endpoint -- Add Cloudflare Workers AI image generation support (#973) -- Add DeepSeek V4 Pro model and update V4 pricing (#938) -- Add captain-definition for Caprover deployment (#954) - -## Improvements -- Optimize slow page load performance -- Refactor connection proxy configuration logic (#970) - -## Fixes -- Prevent cached settings responses (#951) -- Normalize Ollama Local provider input (#955) - -## Docs -- Add Chinese translation of README (#957) -- Fix localized README links (#956) - -# v0.4.20 (2026-05-07) - -## Features -- Add CommandCode provider support - -# v0.4.19 (2026-05-07) - -## Features -- Add OllamaLocalExecutor cho local Ollama provider -- Add audio input support cho Gemini translation -- Add configurable tunnel transport protocols -- Add model deselection trong ComboFormModal & ComboDetailPage -- ComboFormModal/BaseUrlSelect: cloud endpoint option, custom URL local state, default first option -- New API: `/v1/audio/voices`, `/v1/models/info`; `/v1/models` filter disabled models -- CLI tool cards refactor dùng BaseUrlSelect - -## Fixes -- Fix compatible provider API key setup -- Fix usage: filter `totalRequests` theo time period đã chọn -- Fix Kiro IDE MITM handler bugs (AWS CodeWhisperer translation) -- geminiHelper: `ensureObjectType` cho schemas có properties nhưng thiếu type -- initializeApp: guard tunnel/tailscale auto-resume once-per-process - -# v0.4.18 (2026-05-05) - -## Features -- Speech-to-Text: full pipeline with sttCore + /v1/audio/transcriptions; configs for OpenAI, Gemini, Groq, Deepgram, AssemblyAI, HuggingFace, NVIDIA Parakeet; new 9router-stt skill -- Gemini TTS: dedicated provider with 30 prebuilt voices -- Usage quotas: GLM (intl/cn) and MiniMax (intl/cn) fetchers; Gemini CLI usage via retrieveUserQuota per-model buckets -- Disabled models: lowdb-backed disabledModelsDb + /api/models/disabled route -- Header search: reusable Zustand store wired into Header -- CLI tools: Claude Cowork tool card + cowork-settings API -- Providers: mediaPriority sorting in getProvidersByKind, add Kimi K2.6 - -## Improvements -- Expand media-providers/[kind]/[id] page; enhance OAuthModal, ModelSelectModal, ProviderTopology, ProxyPools, ProviderLimits -- Refresh provider icons (alicode, byteplus, cloudflare-ai, nvidia, ollama, vertex, volcengine-ark); add aws-polly, fal-ai, jina-ai, recraft, runwayml, stability-ai, topaz, black-forest-labs -- Reorder hermes provider, drop qwen STT kind - -## Fixes -- Fix skills metadata/text in 9router, chat, embeddings, image, tts, web-fetch, web-search SKILL.md and skills page - -# v0.4.16 (2026-05-04) - -## Features -- Skills system: manage and execute custom AI skills - -## Fixes -- Fix input fields in tool cards - -# v0.4.14 (2026-05-03) - -## Improvements -- Token refresh: in-flight request caching to prevent race conditions & reduce duplicate API calls -- Token refresh: handle unrecoverable errors with token reuse/invalidation -- MITM server: handle port 443 conflicts (kill occupying process before start) -- Better UX feedback in MitmServerCard for port conflicts & admin privileges -- Refactor ComboList for streamlined media provider combos display - -# v0.4.13 (2026-05-03) - -## Features -- Add Azure OpenAI as dedicated provider (endpoint/deployment/API version/organization config) -- Add browser-local endpoint presets for CLI tools (Claude, Codex, OpenCode, Droid, OpenClaw, Hermes, Copilot) -- Add Codex review model quota support -- Add DNS tool state persistence in MITM manager - -## Improvements -- New brand color palette with better light/dark theme consistency -- Improve mobile layouts and restore Cloudflare provider -- Improve zh-CN translations -- Better admin privilege feedback in MitmServerCard -- Refined APIPageClient layout -- Filter LLM combos to show only relevant data - -## Fixes -- Include alias-backed models in /v1/models listing -- Improve cloudflared exit code error messages -- Redirect ~/.9router to DATA_DIR in Docker (persist usage across updates) -- Prevent SSE listener leak in console-logs stream -- Gate MITM sudo prompts on server platform -- Fix Azure validation and persistence (providerSpecificData, Organization required) - -# v0.4.12 (2026-05-01) - -## Features -- Add Xiaomi MiMo provider support -- Add sticky round-robin strategy for combos - -## Improvements -- Refactor proxyFetch and enhance MediaProviderDetailPage layout -- Improve dashboard responsive layouts -- Update provider models list - -## Fixes -- Fix custom provider prefix conflicts with built-in alias -- Strip output_config for MiniMax requests - -# v0.4.11 (2026-04-30) - -## Features -- Add Caveman feature: terse-style system prompts to reduce output token usage with configurable compression levels -- Add Caveman settings UI in Endpoint dashboard (enable/disable, compression level) - -## Improvements -- Consolidate AntigravityExecutor function declarations for Gemini compatibility -- Clean up translator initialization logs across API routes - -# v0.4.10 (2026-04-29) - -## Features -- Add new embedding models and Voyage AI provider support -- Add Coqui, Inworld, Tortoise TTS providers -- Add Deepgram and Inworld TTS voices API endpoints - -## Improvements -- Enhance MITM Antigravity handler with improved cert install and DNS config -- Refactor TTS handling to support additional providers -- Improve API key validation for media providers -- Enhance MITM logger with better diagnostics -- Add Windows elevated permissions support for MITM - -## Fixes -- Fix Antigravity MITM connection and handler issues -- Fix cloudflared tunnel integration with MITM - -# v0.4.8 (2026-04-28) - -## Features -- Add Web Search & Web Fetch providers with Combo support — chain multiple search/fetch providers as a single virtual provider -- Add Cloudflare AI provider support -- Add provider filter and expiry sorting to quota dashboard (#769) - -## Improvements -- Proxy-aware token refresh across executors (Antigravity, Base, Default, Github, Kiro) - -## Fixes -- Fix granular `reasoning_effort` handling for Claude models on Copilot & Anthropic backend (#791) -- Fix Antigravity INVALID_ARGUMENT errors and Copilot agent mode parity -- Fix quota reset timestamp parsing (#768) - -# v0.4.6 (2026-04-25) - -## Features -- Add BytePlus Provider -- Add Codex support to image providers -- Enhance image and embedding provider support - -## Improvements -- Cap maximum cooldown for rate limit handling in account unavailability and single-model chat flows -- Dynamic custom model fetching for model selection - -# v0.4.5 (2026-04-24) - -## Improvements -- Cap maximum cooldown for rate limit handling in account unavailability and single-model chat flows -- Dynamic custom model fetching for model selection - -# v0.4.3 (2026-04-24) - -## Improvements -- Improve in-app download/update UX on dashboard -- Improve Codex provider rate limit handling with precise cooldown (`resetsAtMs`) and email backfill for OAuth accounts - -# v0.4.2 (2026-04-24) - -## Features -- Add Azure OpenAI provider support -- Add built-in Volcengine Ark provider support (#741) -- Add GPT 5.5 model - -## Fixes -- Enhance retry logic and configuration for HTTP status codes - -# v0.4.1 (2026-04-23) - -## Features -- Add Hermes CLI tool with settings management and integration -- Add in-app version update mechanism (appUpdater + /api/version/update) - -## Improvements -- Strengthen CLI token validation for enhanced security -- Enhance Sidebar layout for CLI tools -- Update executors and runtime config - -# v0.3.98 (2026-04-22) - -## Features -- Add RTK — filter context (ls/grep/find/.....) before sending to LLM to save tokens - -# v0.3.97 (2026-04-22) - -## Features -- Add OpenCode Go provider and support for custom models -- Add Text To Image provider -- Support custom host URL for remote Ollama servers - -## Fixes -- Fix copy to clipboard issue - -# v0.3.96 (2026-04-17) - -## Features -- Add marked package for Markdown rendering -- Enhance changelog styles - -## Improvements -- Refactor error handling to config-driven approach with centralized error rules -- Refactor localDb structure -- Update Qwen executor for OAuth handling -- Enhance error formatting to include low-level cause details -- Refactor HeaderMenu to use MenuItem component -- Improve LanguageSwitcher to support controlled open state -- Update backoff configuration and improve CLI detection messages -- Add installation guides for manual configuration in tool cards (Droid, Claude, OpenClaw) - -## Fixes -- Fix Codex image URL fetches to await before sending upstream (#575) -- Strip thinking/reasoning_effort for GitHub Copilot chat completions (#623) -- Enable Codex Apply/Reset buttons when CLI is installed (#591) -- Show manual config option when Claude CLI detection fails (#589) -- Show manual config option when OpenClaw detection fails (#579) -- Ensure LocalMutex acquire returns release callback correctly (#569) -- Strip enumDescriptions from tool schema in antigravity-to-openai (#566) -- Strip temperature parameter for gpt-5.4 model (#536) -- Add Blackbox AI as a supported provider (#599) -- Add multi-model support for Factory Droid CLI tool (#521) -- Add GLM-5 and MiniMax-M2.5 models to Kiro provider (#580) -- Fix usage tracking bug - -# v0.3.91 (2026-04-15) - -## Features -- Add Kiro AWS Identity Center device flow for provider OAuth -- Add TTS (Text-to-Speech) core handler and TTS models config -- Add media providers dashboard page -- Add suggested models API endpoint - -## Improvements -- Refactor error handling to config-driven approach with centralized error rules -- Refactor localDb and usageDb for cleaner structure - -## Fixes -- Fix usage tracking bug - -# v0.3.90 (2026-04-14) - -## Features -- Add proactive token refresh lead times for providers and Codex proxy management -- Enhance CodexExecutor with compact URL support - -## Improvements -- Enhance Windows Tailscale installation with curl support and fallback to well-known Windows path -- Refactor execSync and spawn calls with windowsHide option for better Windows compatibility - -## Fixes -- Fix noAuth support for providers and adjusted MITM restart settings -- Bug fixes - -# v0.3.89 (2026-04-13) - -## Improvements -- Improved dashboard access control by blocking tunnel/Tailscale access when disabled +- Fix zoom controls contrast in topology view (#1066) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3cf992c6830..4acd4c053e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,14 @@ FROM base AS builder RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers -COPY package.json ./ +COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ - npm install + npm ci COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 -RUN npm run build +RUN --mount=type=cache,target=/app/.next/cache \ + npm run build FROM ${NODE_IMAGE} AS runner WORKDIR /app @@ -26,20 +27,22 @@ ENV HOSTNAME=0.0.0.0 ENV NEXT_TELEMETRY_DISABLED=1 ENV DATA_DIR=/app/data -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/open-sse ./open-sse +# --chown at copy time sets node:node ownership directly, avoiding a slow +# recursive `chown -R /app` over thousands of node_modules/next files each build. +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/open-sse ./open-sse # Next file tracing can omit sibling files; MITM runs server.js as a separate process. -COPY --from=builder /app/src/mitm ./src/mitm +COPY --from=builder --chown=node:node /app/src/mitm ./src/mitm # Standalone node_modules may omit deps only required by the MITM child process. -COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge +COPY --from=builder --chown=node:node /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 +COPY --from=builder --chown=node:node /app/node_modules/next ./node_modules/next # nodemailer is loaded via dynamic import for SMTP email; tracing can miss it. -COPY --from=builder /app/node_modules/nodemailer ./node_modules/nodemailer +COPY --from=builder --chown=node:node /app/node_modules/nodemailer ./node_modules/nodemailer -RUN mkdir -p /app/data && chown -R node:node /app && \ +RUN mkdir -p /app/data && chown node:node /app /app/data && \ mkdir -p /app/data-home && chown node:node /app/data-home && \ ln -sf /app/data-home /root/.9router 2>/dev/null || true diff --git a/README.md b/README.md index 4a6c54b342d..80ef54eec5b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) - [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) + [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) --- diff --git a/cli/package.json b/cli/package.json index 786be3c11c7..e5923d7be4d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.66", + "version": "0.4.71", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 8baff269de6..d98e9d64bcc 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -55,6 +55,7 @@ const PROVIDER_MODELS = { ag: [ { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, + { id: "gemini-3.5-flash-extra-low" }, { id: "gemini-pro-agent" }, { id: "gemini-3.1-pro-low" }, { id: "claude-sonnet-4-6" }, diff --git a/i18n/README.ru.md b/i18n/README.ru.md new file mode 100644 index 00000000000..01ed17b4145 --- /dev/null +++ b/i18n/README.ru.md @@ -0,0 +1,1311 @@ +
+ Панель управления 9Router + + # 9Router - Free AI Router + + **Никогда не прекращайте кодить. Автоматическая маршрутизация к БЕСПЛАТНЫМ и дешёвым 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 решает это:** + +- ✅ **Максимум из подписки** — Отслеживает квоту, использует каждый бит до сброса +- ✅ **Автоматическое резервирование** — Подписка → Дёшево → Бесплатно, нулевой простой +- ✅ **Несколько аккаунтов** — Round-robin по аккаунтам каждого провайдера +- ✅ **Универсальность** — Работает с Claude Code, Codex, Gemini CLI, Cursor, Cline, любым CLI-инструментом + +--- + +## 🔄 Как это работает + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [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) + +Result: Never stop coding, minimal cost +``` + +--- + +## ⚡ Быстрый старт + +**1. Глобальная установка:** + +```bash +npm install -g 9router +9router +``` + +🎉 Панель управления откроется на `http://localhost:20128` + +**2. Подключите БЕСПЛАТНОГО провайдера (без подписки):** + +Панель управления → Providers → Подключить **Claude Code** или **Antigravity** → Вход через OAuth → Готово! + +**3. Используйте в вашем CLI-инструменте:** + +``` +Настройки Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [скопируйте из панели управления] + Model: if/kimi-k2-thinking +``` + +**Готово!** Начинайте кодить с БЕСПЛАТНЫМИ AI-моделями. + +**Альтернатива: запуск из исходников (этот репозиторий):** + +Пакет этого репозитория приватный (`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-совместимый API: `http://localhost:20128/v1` + +--- + +## 🎥 Видео-руководство + +
+ +### 📺 Полное руководство по настройке - 9Router + Claude Code БЕСПЛАТНО + +[![Настройка 9Router + Claude Code](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0) + +**🎬 Полное пошаговое руководство:** +- ✅ Установка и настройка 9Router +- ✅ Настройка Claude Sonnet 4.5 БЕСПЛАТНО +- ✅ Интеграция с Claude Code +- ✅ Тестирование кода вживую + +**⏱️ Длительность:** 20 минут | **👥 Автор:** Сообщество разработчиков + +[▶️ Смотреть на YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg) + +
+ +--- + +## 🛠️ Поддерживаемые 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 +
+
+ +### 🆓 Бесплатные провайдеры + +
+ + + + + + + +
+ iFlow
+ iFlow AI
+ 8+ моделей • Без ограничений +
+ Qwen
+ Qwen Code
+ 3+ моделей • Без ограничений +
+ Gemini CLI
+ Gemini CLI
+ 180K/мес БЕСПЛАТНО +
+ Kiro
+ Kiro AI
+ Claude • Без ограничений +
+
+ +### 🔑 Провайдеры с 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-совместимые эндпоинты

+
+ +--- + +## 💡 Ключевые возможности + +| Возможность | Что делает | Почему это важно | +|---------|--------------|----------------| +| 🎯 **Smart 3-Tier Fallback** | Авто-маршрутизация: Подписка → Дёшево → Бесплатно | Никогда не прекращайте кодить, нулевой простой | +| 📊 **Отслеживание квоты в реальном времени** | Живой подсчёт токенов + обратный отсчёт до сброса | Максимум ценности из подписки | +| 🔄 **Трансляция форматов** | OpenAI ↔ Claude ↔ Gemini бесшовно | Работает с любым CLI-инструментом | +| 👥 **Поддержка нескольких аккаунтов** | Несколько аккаунтов на каждого провайдера | Балансировка нагрузки + резервирование | +| 🔄 **Авто-обновление токена** | OAuth-токены обновляются автоматически | Не нужно входить вручную заново | +| 🎨 **Пользовательские комбо** | Создавайте безграничные комбинации моделей | Настройте резервирование под себя | +| 📝 **Логирование запросов** | Режим отладки с полным логом запросов/ответов | Лёгкая диагностика проблем | +| 💾 **Облачная синхронизация** | Синхронизация конфигурации между устройствами | Одинаковые настройки везде | +| 📊 **Аналитика использования** | Отслеживание токенов, затрат, трендов во времени | Оптимизация расходов | +| 🌐 **Развёртывание где угодно** | Localhost, VPS, Docker, Cloudflare Workers | Гибкие варианты развёртывания | + +
+📖 Подробности о возможностях + +### 🎯 Smart 3-Tier Fallback + +Создавайте комбо с автоматическим резервированием: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (ваша подписка) + 2. glm/glm-4.7 (дешёвый бэкап, $0.6/1M) + 3. if/kimi-k2-thinking (бесплатное резервирование) + +→ Автопереключение при исчерпании квоты или ошибке +``` + +### 📊 Отслеживание квоты в реальном времени + +- Потребление токенов по каждому провайдеру +- Обратный отсчёт до сброса (5 часов, ежедневно, еженедельно) +- Оценка затрат для платных уровней +- Ежемесячный отчёт о расходах + +### 🔄 Трансляция форматов + +Бесшовная трансляция между форматами: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Ваш CLI-инструмент отправляет формат OpenAI → 9Router транслирует → Провайдер получает родной формат +- Работает с любым инструментом, поддерживающим пользовательский эндпоинт OpenAI + +### 👥 Поддержка нескольких аккаунтов + +- Добавляйте несколько аккаунтов на каждого провайдера +- Round-robin или маршрутизация по приоритету автоматически +- Резервирование на следующий аккаунт при достижении квоты + +### 🔄 Авто-обновление токена + +- OAuth-токены автоматически обновляются до истечения срока +- Не нужна повторная ручная аутентификация +- Бесшовный опыт со всеми провайдерами + +### 🎨 Пользовательские комбо + +- Создавайте безграничные комбинации моделей +- Сочетайте уровни подписки, дешёвые и бесплатные +- Называйте комбо для удобного доступа +- Делитесь комбо между устройствами через облачную синхронизацию + +### 📝 Логирование запросов + +- Включите режим отладки для просмотра полного лога запросов/ответов +- Отслеживайте вызовы API, заголовки и payload +- Диагностируйте проблемы интеграции +- Экспортируйте логи для анализа + +### 💾 Облачная синхронизация + +- Синхронизация провайдеров, комбо и настроек между устройствами +- Автоматическая фоновая синхронизация +- Безопасное зашифрованное хранилище +- Доступ к настройкам откуда угодно + +#### Заметки о облачном рантайме + +- Приоритет серверным облачным переменным в production-окружении: + - `BASE_URL` (внутренний callback URL, используемый планировщиком синхронизации) + - `CLOUD_URL` (база эндпоинта облачной синхронизации) +- `NEXT_PUBLIC_BASE_URL` и `NEXT_PUBLIC_CLOUD_URL` по-прежнему поддерживаются для совместимости/UI, но серверный рантайм теперь приоритезирует `BASE_URL`/`CLOUD_URL`. +- Запросы облачной синхронизации теперь используют тайм-аут + fail-fast поведение, чтобы избежать зависания UI при недоступности DNS/облачной сети. + +### 📊 Аналитика использования + +- Отслеживание использования токенов по провайдеру и модели +- Оценка затрат и тренды расходов +- Ежемесячные отчёты и инсайты +- Оптимизация ваших AI-расходов + +> **💡 ВАЖНО - Понимание «Затрат» на панели управления:** +> +> «Затраты», показанные в Аналитике использования, предназначены **только для отслеживания и сравнения**. +> Сам 9Router **никогда ничего не взимает** с вас. Вы платите напрямую провайдерам (если используете платные сервисы). +> +> **Пример:** Если на панели показано «общие затраты $290» при использовании моделей iFlow, это представляет +> сумму, которую вы заплатили бы при прямом использовании платного API. Ваши фактические затраты = **$0** (iFlow бесплатен без ограничений). +> +> Считайте это «трекером экономии», показывающим, сколько вы экономите, используя бесплатные модели или +> маршрутизацию через 9Router! + +### 🌐 Развёртывание где угодно + +- 💻 **Localhost** — По умолчанию, работает офлайн +- ☁️ **VPS/Cloud** — Общий доступ между устройствами +- 🐳 **Docker** — Развёртывание одной командой +- 🚀 **Cloudflare Workers** — Глобальная edge-сеть + +
+ +--- + +## 💰 Обзор цен + +| Уровень | Провайдер | Стоимость | Сброс квоты | Лучше всего для | +|------|----------|------|-------------|----------| +| **💳 ПОДПИСКА** | Claude Code (Pro) | $20/мес | 5ч + еженедельно | Уже подписаны | +| | Codex (Plus/Pro) | $20-200/мес | 5ч + еженедельно | Пользователи OpenAI | +| | Gemini CLI | **БЕСПЛАТНО** | 180K/мес + 1K/день | Для всех! | +| | GitHub Copilot | $10-19/мес | Ежемесячно | Пользователи GitHub | +| **💰 ДЁШЕВО** | GLM-4.7 | $0.6/1M | 10:00 ежедневно | Бюджетный бэкап | +| | MiniMax M2.1 | $0.2/1M | Скользящие 5 часов | Самый дешёвый вариант | +| | Kimi K2 | $9/мес фикс. | 10M токенов/мес | Предсказуемая стоимость | +| **🆓 БЕСПЛАТНО** | iFlow | $0 | Без ограничений | 8 бесплатных моделей | +| | Qwen | $0 | Без ограничений | 3 бесплатные модели | +| | Kiro | $0 | Без ограничений | Claude бесплатно | + +**💡 Профи-совет:** Начните с комбо Gemini CLI (180K бесплатно/мес) + iFlow (без ограничений бесплатно) = $0 затрат! + +--- + +### 📊 Понимание затрат и оплаты в 9Router + +**Реальность оплаты 9Router:** + +✅ **Софт 9Router = БЕСПЛАТНО навсегда** (открытый код, никогда не взимает плату) +✅ **«Затраты» на панели = Только для отображения/отслеживания** (не реальный счёт) +✅ **Вы платите напрямую провайдерам** (подписка или плата за API) +✅ **БЕСПЛАТНЫЕ провайдеры остаются БЕСПЛАТНЫМИ** (iFlow, Kiro, Qwen = $0 без ограничений) +❌ **9Router никогда не выставляет счёт** и не списывает с вашей карты + +**Как работает отображение затрат:** + +Панель показывает **оценочные затраты**, как если бы вы напрямую использовали платный API. Это **не оплата** — это инструмент сравнения, показывающий вашу экономию. + +**Пример сценария:** +``` +Показано на панели: +• Всего запросов: 1,662 +• Всего токенов: 47M +• Отображаемые затраты: $290 + +Реальная проверка: +• Провайдер: iFlow (БЕСПЛАТНО без ограничений) +• Фактическая оплата: $0.00 +• Значение $290: Сумма, которую вы СЭКОНОМИЛИ, используя бесплатные модели! +``` + +**Правила оплаты:** +- **Провайдеры подписки** (Claude Code, Codex): Платите им напрямую через их сайт +- **Дешёвые провайдеры** (GLM, MiniMax): Платите им напрямую, 9Router только маршрутизирует +- **БЕСПЛАТНЫЕ провайдеры** (iFlow, Kiro, Qwen): Действительно бесплатны навсегда, без скрытых платежей +- **9Router**: Никогда ничего не взимает, никогда + +--- + +## 🎯 Сценарии использования + +### Сценарий 1: «У меня подписка Claude Pro» + +**Проблема:** Квота сгорает неиспользованной, rate limit при интенсивной работе + +**Решение:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (полное использование подписки) + 2. glm/glm-4.7 (дешёвый бэкап при исчерпании квоты) + 3. if/kimi-k2-thinking (бесплатное аварийное резервирование) + +Месячная стоимость: $20 (подписка) + ~$5 (бэкап) = $25 итого +против $20 + упирание в лимит = разочарование +``` + +### Сценарий 2: «Хочу нулевые затраты» + +**Проблема:** Не могу позволить подписку, нужен надёжный AI-кодинг + +**Решение:** +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180K бесплатно/мес) + 2. if/kimi-k2-thinking (без ограничений бесплатно) + 3. qw/qwen3-coder-plus (без ограничений бесплатно) + +Месячная стоимость: $0 +Качество: Production-ready модели +``` + +### Сценарий 3: «Нужно кодить 24/7, без перерывов» + +**Проблема:** Дедлайны, нельзя допустить простоя + +**Решение:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (лучшее качество) + 2. cx/gpt-5.2-codex (вторая подписка) + 3. glm/glm-4.7 (дёшево, ежедневный сброс) + 4. minimax/MiniMax-M2.1 (самый дешёвый, сброс 5ч) + 5. if/kimi-k2-thinking (бесплатно без ограничений) + +Результат: 5 слоёв резервирования = нулевой простой +Месячная стоимость: $20-200 (подписки) + $10-20 (бэкап) +``` + +### Сценарий 4: «Хочу БЕСПЛАТНЫЙ AI в OpenClaw» + +**Проблема:** Нужен AI-ассистент в мессенджерах (WhatsApp, Telegram, Slack...), полностью бесплатно + +**Решение:** +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (без ограничений бесплатно) + 2. if/minimax-m2.1 (без ограничений бесплатно) + 3. if/kimi-k2-thinking (без ограничений бесплатно) + +Месячная стоимость: $0 +Доступ через: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Часто задаваемые вопросы + +
+📊 Почему моя панель показывает высокие затраты? + +Панель отслеживает ваше использование токенов и показывает **оценочные затраты**, как если бы вы напрямую использовали платный API. Это **не реальная оплата** — это справка, показывающая, сколько вы экономите, используя бесплатные модели или существующие подписки через 9Router. + +**Пример:** +- **Панель показывает:** «Общие затраты $290» +- **Реальность:** Вы используете iFlow (БЕСПЛАТНО без ограничений) +- **Ваши фактические затраты:** **$0.00** +- **Значение $290:** Сумма, которую вы **экономите**, используя бесплатные модели вместо платного API! + +Отображение затрат — это «трекер экономии», помогающий понять паттерны использования и возможности оптимизации. + +
+ +
+💳 Взимает ли с меня плату 9Router? + +**Нет.** 9Router — это бесплатное ПО с открытым кодом, работающее на вашем собственном компьютере. Оно никогда ничего с вас не взимает. + +**Вы платите только:** +- ✅ **Провайдерам подписки** (Claude Code $20/мес, Codex $20-200/мес) → Платите им напрямую на их сайте +- ✅ **Дешёвым провайдерам** (GLM, MiniMax) → Платите им напрямую, 9Router только маршрутизирует ваши запросы +- ❌ **Самому 9Router** → **Никогда ничего не взимает, никогда** + +9Router — это локальный прокси/роутер. У него нет вашей кредитной карты, он не может выставлять счета и не имеет платёжной системы. Это полностью бесплатное ПО. + +
+ +
+🆓 Действительно ли БЕСПЛАТНЫЕ провайдеры безлимитны? + +**Да!** Провайдеры, отмеченные как БЕСПЛАТНЫЕ (iFlow, Kiro, Qwen), действительно безлимитны и **без скрытых платежей**. + +Это бесплатные сервисы, предоставляемые соответствующими компаниями: +- **iFlow**: Бесплатный безлимитный доступ к 8+ моделям через OAuth +- **Kiro**: Бесплатные безлимитные модели Claude через AWS Builder ID +- **Qwen**: Бесплатный безлимитный доступ к моделям Qwen через аутентификацию устройства + +9Router только маршрутизирует ваши запросы к ним — никаких «ловушек» или будущих платежей. Это действительно бесплатные сервисы, а 9Router облегчает их использование с поддержкой резервирования. + +**Примечание:** Некоторые провайдеры подписки (Antigravity, GitHub Copilot) могут иметь бесплатные пробные периоды, которые позже становятся платными, но об этом чётко уведомляют сами провайдеры, а не 9Router. + +
+ +
+💰 Как минимизировать мои реальные AI-затраты? + +**Стратегия «Бесплатное в приоритете»:** + +1. **Начните со 100% бесплатного комбо:** + ``` + 1. gc/gemini-3-flash (180K/мес бесплатно от Google) + 2. if/kimi-k2-thinking (без ограничений бесплатно от iFlow) + 3. qw/qwen3-coder-plus (без ограничений бесплатно от Qwen) + ``` + **Стоимость: $0/мес** + +2. **Добавьте дешёвый бэкап** только при необходимости: + ``` + 4. glm/glm-4.7 ($0.6/1M токенов) + ``` + **Доп. стоимость:** Платите только за то, что фактически используете + +3. **Используйте провайдеров подписки в последнюю очередь:** + - Только если они у вас уже есть + - 9Router помогает максимизировать их ценность через отслеживание квоты + +**Результат:** Большинство пользователей могут работать за $0/мес, используя только бесплатные уровни! + +
+ +
+📈 Что если моё использование внезапно вырастет? + +Умный механизм резервирования 9Router предотвращает неожиданные расходы: + +**Сценарий:** Вы в спринте кодинга и превышаете квоты + +**Без 9Router:** +- ❌ Упёрлись в rate limit → Работа остановилась → Разочарование +- ❌ Или: Случайно накопили огромный счёт за API + +**С 9Router:** +- ✅ Подписка упёрлась в лимит → Авторезервирование на дешёвый уровень +- ✅ Дешёвый уровень становится дорогим → Авторезервирование на бесплатный уровень +- ✅ Никогда не прекращаете кодить → Предсказуемая стоимость + +**Вы контролируете:** Установите лимиты расходов на каждого провайдера в панели, и 9Router будет их соблюдать. + +
+ +--- + +## 📖 Руководство по настройке + +
+🔐 Провайдеры подписки (Максимум ценности) + +### Claude Code (Pro/Max) + +```bash +Панель управления → Providers → Подключить Claude Code +→ Вход через OAuth → Авто-обновление токена +→ Отслеживание квоты 5 часов + еженедельно + +Модели: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Профи-совет:** Используйте Opus для сложных задач, Sonnet для скорости. 9Router отслеживает квоту для каждой модели! + +### OpenAI Codex (Plus/Pro) + +```bash +Панель управления → Providers → Подключить Codex +→ Вход через OAuth (порт 1455) +→ Сброс 5 часов + еженедельно + +Модели: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (БЕСПЛАТНО 180K/мес!) + +```bash +Панель управления → Providers → Подключить Gemini CLI +→ Google OAuth +→ 180K запросов/мес + 1K/день + +Модели: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Лучшая ценность:** Огромный бесплатный уровень! Используйте его перед платными уровнями. + +### GitHub Copilot + +```bash +Панель управления → Providers → Подключить GitHub +→ OAuth через GitHub +→ Ежемесячный сброс (1-го числа месяца) + +Модели: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Дешёвые провайдеры (Бэкап) + +### GLM-4.7 (Ежедневный сброс, $0.6/1M) + +1. Регистрация: [Zhipu AI](https://open.bigmodel.cn/) +2. Получите API key из Coding Plan +3. Панель управления → Добавить API Key: + - Провайдер: `glm` + - API Key: `your-key` + +**Использование:** `glm/glm-4.7` + +**Профи-совет:** Coding Plan даёт втрое больше квоты за 1/7 стоимости! Сброс ежедневно в 10:00. + +### MiniMax M2.1 (Сброс 5ч, $0.20/1M) + +1. Регистрация: [MiniMax](https://www.minimax.io/) +2. Получите API key +3. Панель управления → Добавить API Key + +**Использование:** `minimax/MiniMax-M2.1` + +**Профи-совет:** Самый дешёвый вариант для длинного контекста (1M)! + +### Kimi K2 ($9/мес фиксированно) + +1. Регистрация: [Moonshot AI](https://platform.moonshot.ai/) +2. Получите API key +3. Панель управления → Добавить API Key + +**Использование:** `kimi/kimi-latest` + +**Профи-совет:** Фиксированные $9/мес за 10M токенов = реальная стоимость $0.90/1M! + +
+ +
+🆓 БЕСПЛАТНЫЕ провайдеры (Аварийное резервирование) + +### iFlow (8 БЕСПЛАТНЫХ моделей) + +```bash +Панель управления → Подключить iFlow +→ Вход через OAuth iFlow +→ Безлимитное использование + +Модели: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 БЕСПЛАТНЫЕ модели) + +```bash +Панель управления → Подключить Qwen +→ Авторизация по коду устройства +→ Безлимитное использование + +Модели: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (БЕСПЛАТНЫЙ Claude) + +```bash +Панель управления → Подключить Kiro +→ AWS Builder ID или Google/GitHub +→ Безлимитное использование + +Модели: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Создание комбо + +### Пример 1: Максимум из подписки → Дешёвый бэкап + +``` +Панель управления → Combos → Создать новое + +Имя: premium-coding +Модели: + 1. cc/claude-opus-4-6 (Основная подписка) + 2. glm/glm-4.7 (Дешёвый бэкап, $0.6/1M) + 3. minimax/MiniMax-M2.1 (Самое дешёвое резервирование, $0.20/1M) + +Использование в CLI: premium-coding + +Пример месячной стоимости (100M токенов): + 80M через Claude (подписка): $0 дополнительно + 15M через GLM: $9 + 5M через MiniMax: $1 + Итого: $10 + ваша подписка +``` + +### Пример 2: Только бесплатно (Нулевая стоимость) + +``` +Имя: free-combo +Модели: + 1. gc/gemini-3-flash-preview (180K бесплатно/мес) + 2. if/kimi-k2-thinking (без ограничений) + 3. qw/qwen3-coder-plus (без ограничений) + +Стоимость: $0 навсегда! +``` + +
+ +
+🔧 Интеграция CLI + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [из панели управления 9router] + Model: cc/claude-opus-4-6 +``` + +Или используйте комбо: `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 "ваш промпт" +``` + +### OpenClaw + +**Вариант 1 — Панель управления (рекомендуется):** + +``` +Панель управления → CLI Tools → OpenClaw → Выбрать модель → Применить +``` + +**Вариант 2 — Вручную:** Отредактируйте `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/if/glm-4.7" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "if/glm-4.7", + "name": "glm-4.7" + } + ] + } + } + } +} +``` + +> **Примечание:** OpenClaw работает только с локальным 9Router. Используйте `127.0.0.1` вместо `localhost`, чтобы избежать проблем с разрешением имён. + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [из панели управления] +Model: cc/claude-opus-4-6 +``` + +
+ +
+🚀 Развёртывание + +### Развёртывание на VPS + +```bash +# Clone and install +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configure +export JWT="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" + +# Start +npm run start + +# Or use PM2 +npm install -g pm2 +pm2 start --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +```bash +# Build image (from repository root) +docker build -t 9router . + +# Run container (command used in current setup) +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file /root/dev/9router/.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +Портативная команда (если вы уже в корне репозитория): + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file ./.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +Значения по умолчанию контейнера: +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +Полезные команды: + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +``` + +### Переменные окружения + +| Переменная | По умолчанию | Описание | +|----------|---------|-------------| +| `JWT_SECRET` | Автогенерация (`~/.9router/jwt-secret`) | Секрет подписи JWT для cookie аутентификации панели (задайте для общего доступа между инстансами) | +| `INITIAL_PASSWORD` | `123456` | Пароль первого входа при отсутствии сохранённого хеша | +| `DATA_DIR` | `~/.9router` | Расположение основной БД приложения (`db.json`) | +| `PORT` | framework default | Порт сервиса (`20128` в примерах) | +| `HOSTNAME` | framework default | Bind host (Docker по умолчанию `0.0.0.0`) | +| `NODE_ENV` | runtime default | Установите `production` для развёртывания | +| `BASE_URL` | `http://localhost:20128` | Внутренний серверный базовый URL для задач облачной синхронизации | +| `CLOUD_URL` | `https://9router.com` | Серверный базовый URL эндпоинта облачной синхронизации | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Обратно совместимый/публичный базовый URL (приоритет `BASE_URL` для серверного рантайма) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Обратно совместимый/публичный облачный URL (приоритет `CLOUD_URL` для серверного рантайма) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC-секрет для генерируемых API-ключей | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Соль для стабильного хеширования ID машины | +| `ENABLE_REQUEST_LOGS` | `false` | Включить лог запросов/ответов в `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Принудительный `Secure` cookie аутентификации (задайте `true` за HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Требовать Bearer API key на маршрутах `/v1/*` (рекомендуется для развёртываний с выходом в интернет) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Опциональный исходящий прокси для вызовов к провайдерам | + +Примечания: +- Прокси-переменные в нижнем регистре также поддерживаются: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` не запекается в Docker-образ (`.dockerignore`); подавайте runtime-конфигурацию через `--env-file` или `-e`. +- В Windows для разрешения путей локального хранилища может использоваться `APPDATA`. +- `INSTANCE_NAME` встречается в старых docs/env-шаблонах, но сейчас в рантайме не используется. + +### Runtime-файлы и хранилище + +- Основное состояние приложения: `${DATA_DIR}/db.json` (провайдеры, комбо, alias, ключи, настройки), управляется `src/lib/localDb.js`. +- История использования и логи: `~/.9router/usage.json` и `~/.9router/log.txt`, управляется `src/lib/usageDb.js`. +- Опциональные логи запросов/транслятора: `/logs/...` при `ENABLE_REQUEST_LOGS=true`. +- Хранилище использования следует логике пути `~/.9router` и независимо от `DATA_DIR`. + +
+ +--- + +## 📊 Доступные модели + +
+Показать все доступные модели + +**Claude Code (`cc/`)** - Pro/Max: +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**Gemini CLI (`gc/`)** - БЕСПЛАТНО: +- `gc/gemini-3-flash-preview` +- `gc/gemini-2.5-pro` + +**GitHub Copilot (`gh/`)**: +- `gh/gpt-5` +- `gh/claude-4.5-sonnet` + +**GLM (`glm/`)** - $0.6/1M: +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: +- `minimax/MiniMax-M2.1` + +**iFlow (`if/`)** - БЕСПЛАТНО: +- `if/kimi-k2-thinking` +- `if/qwen3-coder-plus` +- `if/deepseek-r1` + +**Qwen (`qw/`)** - БЕСПЛАТНО: +- `qw/qwen3-coder-plus` +- `qw/qwen3-coder-flash` + +**Kiro (`kr/`)** - БЕСПЛАТНО: +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` + +
+ +--- + +## 🐛 Устранение неполадок + +**"Language model did not provide messages"** +- Исчерпана квота провайдера → Проверьте трекер квоты на панели +- Решение: Используйте резервирование комбо или переключитесь на более дешёвый уровень + +**Ограничение скорости (Rate limiting)** +- Исчерпана квота подписки → Резервирование на GLM/MiniMax +- Добавьте комбо: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking` + +**OAuth-токен истёк** +- Автообновление 9Router +- Если проблема сохраняется: Панель управления → Провайдеры → Переподключить + +**Высокие затраты** +- Проверьте статистику использования в панели +- Переключите основную модель на GLM/MiniMax +- Используйте бесплатные уровни (Gemini CLI, iFlow) для некритичных задач + +**Панель открывается на неверном порту** +- Установите `PORT=20128` и `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**Ошибки облачной синхронизации** +- Убедитесь, что `BASE_URL` указывает на ваш работающий инстанс (например, `http://localhost:20128`) +- Убедитесь, что `CLOUD_URL` указывает на ожидаемый облачный эндпоинт (например, `https://9router.com`) +- По возможности держите значения `NEXT_PUBLIC_*` согласованными с серверными значениями. + +**Облачный эндпоинт `stream=false` возвращает 500 (`Unexpected token 'd'...`)** +- Симптом обычно появляется на публичном облачном эндпоинте (`https://9router.com/v1`) для непотоковых (non-streaming) вызовов. +- Корневая причина: upstream возвращает SSE-payload (`data: ...`), тогда как клиент ожидает JSON. +- Обходное решение: используйте `stream=true` для прямых вызовов в облако. +- Локальный рантайм 9Router включает резервирование SSE→JSON для непотоковых вызовов, когда upstream возвращает `text/event-stream`. + +**Облако сообщает о подключении, но запрос всё равно падает с `Invalid API key`** +- Создайте новый ключ в локальной панели (`/api/keys`) и запустите облачную синхронизацию (`Enable Cloud`, затем `Sync Now`). +- Старые/несинхронизированные ключи могут возвращать `401` в облаке, даже если локальный эндпоинт работает. + +**Первый вход не работает** +- Проверьте `INITIAL_PASSWORD` в `.env` +- Если не задан, резервный пароль — `123456` + +**Нет логов запросов в `logs/`** +- Установите `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind 4 +- **Database**: LowDB (на основе JSON-файлов) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 Справочник по 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": "Напиши функцию для..."} + ], + "stream": true +} +``` + +### Список моделей + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Возвращает все модели + комбо в формате OpenAI +``` + +### Совместимые эндпоинты + +- `POST /v1/chat/completions` +- `POST /v1/messages` +- `POST /v1/responses` +- `GET /v1/models` +- `POST /v1/messages/count_tokens` +- `GET /v1beta/models` +- `POST /v1beta/models/{...path}` (Gemini-style `generateContent`) +- `POST /v1/api/chat` (путь конвертации в стиле Ollama) + +### Скрипты облачной аутентификации + +Добавлены тестовые скрипты в `tester/security/`: + +- `tester/security/test-docker-hardening.sh` + - Собирает Docker-образ и проверяет hardening-проверки (`/api/cloud/auth` auth guard, `REQUIRE_API_KEY`, безопасное поведение cookie аутентификации). +- `tester/security/test-cloud-openai-compatible.sh` + - Отправляет OpenAI-совместимый запрос напрямую на облачный эндпоинт (`https://9router.com/v1/chat/completions`) с указанной моделью/ключом. +- `tester/security/test-cloud-sync-and-call.sh` + - End-to-end процесс: создание локального ключа → включение/синхронизация облака → вызов облачного эндпоинта с повтором. + - Включает резервную проверку с `stream=true`, чтобы отличить ошибки аутентификации от проблем разбора потока. + +Заметки по безопасности для облачных тестовых скриптов: + +- Никогда не хардкодьте реальные API-ключи в скриптах/коммитах. +- Передавайте ключи только через переменные окружения: + - `API_KEY`, `CLOUD_API_KEY` или `OPENAI_API_KEY` (поддерживается `test-cloud-openai-compatible.sh`) +- Пример: + +```bash +OPENAI_API_KEY="your-cloud-key" bash tester/security/test-cloud-openai-compatible.sh +``` + +Ожидаемое поведение по результатам недавней проверки: + +- Локально (`http://127.0.0.1:20128/v1/chat/completions`): работает с `stream=false` и `stream=true`. +- Docker-рантайм (тот же API-путь, экспонируемый контейнером): hardening-проверки проходят, cloud auth guard работает, строгий режим API-ключа работает при включении. +- Публичный облачный эндпоинт (`https://9router.com/v1/chat/completions`): + - `stream=true`: ожидается успех (возвращает SSE-чанки). + - `stream=false`: может падать с `500` + ошибкой разбора (`Unexpected token 'd'`), когда upstream возвращает SSE-контент для непотокового клиентского пути. + +### API управления и панели + +- Аутентификация/настройки: `/api/auth/login`, `/api/auth/logout`, `/api/settings`, `/api/settings/require-login` +- Управление провайдерами: `/api/providers`, `/api/providers/[id]`, `/api/providers/[id]/test`, `/api/providers/[id]/models`, `/api/providers/validate`, `/api/provider-n*` +- OAuth-потоки: `/api/oauth/[provider]/[action]` (+ специфичные для провайдеров импорты, такие как Cursor/Kiro) +- Конфигурация маршрутизации: `/api/models/alias`, `/api/combos*`, `/api/keys*`, `/api/pricing` +- Использование/логи: `/api/usage/history`, `/api/usage/logs`, `/api/usage/request-logs`, `/api/usage/[connectionId]` +- Облачная синхронизация: `/api/sync/cloud`, `/api/sync/initialize`, `/api/cloud/*` +- Помощники CLI: `/api/cli-tools/claude-settings`, `/api/cli-tools/codex-settings`, `/api/cli-tools/droid-settings`, `/api/cli-tools/openclaw-settings` + +### Поведение аутентификации + +- Маршруты панели (`/dashboard/*`) используют защиту cookie `auth_token`. +- Вход использует сохранённый хеш пароля при наличии; иначе откатывается к `INITIAL_PASSWORD`. +- `requireLogin` можно переключить через `/api/settings/require-login`. + +### Обработка запросов (высокоуровнево) + +1. Клиент отправляет запрос на `/v1/*`. +2. Обработчик маршрута вызывает `handleChat` (`src/sse/handlers/chat.js`). +3. Модель разрешается (прямой провайдер/модель или разрешение alias/combo). +4. Учётные данные выбираются из локальной БД с фильтром доступности аккаунта. +5. `handleChatCore` (`open-sse/handlers/chatCore.js`) определяет формат и транслирует запрос. +6. Исполнитель провайдера отправляет upstream-запрос. +7. Поток при необходимости транслируется обратно в клиентский формат. +8. Использование/логи записываются (`src/lib/usageDb.js`). +9. Резервирование применяется при ошибках провайдера/аккаунта/модели по правилам комбо. + +Полный справочник по архитектуре: [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) + +--- + +## 📧 Поддержка + +- **Сайт**: [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=100&columns=20&anon=1)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Star Chart + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +### Как внести вклад + +1. Сделайте форк репозитория +2. Создайте свою feature-ветку (`git checkout -b feature/amazing-feature`) +3. Закоммитьте изменения (`git commit -m 'Add amazing feature'`) +4. Запушьте в ветку (`git push origin feature/amazing-feature`) +5. Откройте Pull Request + +См. [Pull Requests](https://github.com/decolua/9router/pulls) для подробных инструкций. + +--- + +## 🔀 Форки + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Полнофункциональный TypeScript-форк 9Router. Добавляет 36+ провайдеров, авторезервирование на 4 уровнях, мультимодальный API (изображения, embedding, аудио, TTS), circuit breaker, семантическое кеширование, оценку LLM и доработанную панель. 368+ юнит-тестов. Доступен через npm. + +--- + +## 🙏 Благодарности + +Особая благодарность **CLIProxyAPI** — оригинальной Go-реализации, вдохновившей этот JavaScript-порт. + +--- + +## 📄 Лицензия + +Лицензия MIT — см. [LICENSE](../LICENSE) для деталей. + +--- + +
+ Создано с ❤️ для разработчиков, которые кодят 24/7 +
diff --git a/next.config.mjs b/next.config.mjs index 85c7a258f9a..3853b4b00bb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,6 +7,7 @@ const projectRoot = dirname(fileURLToPath(import.meta.url)); const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace" ? join(projectRoot, "..") : projectRoot; +const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE || "128mb"; /** @type {import('next').NextConfig} */ const nextConfig = { @@ -24,6 +25,10 @@ const nextConfig = { unoptimized: true }, env: {}, + experimental: { + // #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites. + proxyClientMaxBodySize, + }, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle if (!isServer) { diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index bc2e2a0e9da..8f51513224a 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -82,9 +82,9 @@ export const PROVIDER_MODELS = { { id: "iflow-rome-30ba3b", name: "iFlow ROME" }, ], ag: [ // Antigravity - special case: models call different backends - { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Extra Low)" }, { 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)" }, { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" }, @@ -153,6 +153,7 @@ export const PROVIDER_MODELS = { { id: "lite", name: "Qoder Lite" }, // Frontier models — pin a specific backing model { 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)" }, @@ -337,6 +338,7 @@ export const PROVIDER_MODELS = { { id: "kimi-latest", name: "Kimi Latest" }, ], minimax: [ + { id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "MiniMax-M2.1", name: "MiniMax M2.1" }, @@ -363,6 +365,7 @@ export const PROVIDER_MODELS = { { id: "qwen3-vl-plus", name: "Qwen3 VL Plus" }, ], "minimax-cn": [ + { id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" }, { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "MiniMax-M2.1", name: "MiniMax M2.1" }, @@ -547,6 +550,7 @@ export const PROVIDER_MODELS = { ], "xiaomi-tokenplan": [ { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, + { id: "mimo-v2.5-pro-claude", name: "MiMo V2.5 Pro (Claude Native)", targetFormat: "claude", upstreamModelId: "mimo-v2.5-pro" }, { id: "mimo-v2.5", name: "MiMo V2.5" }, { id: "mimo-v2-pro", name: "MiMo V2 Pro" }, { id: "mimo-v2-omni", name: "MiMo V2 Omni" }, @@ -854,6 +858,13 @@ export function getModelTargetFormat(aliasOrId, modelId) { return found?.targetFormat || null; } +export function getModelType(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return null; + const found = models.find(m => m.id === modelId); + return found?.type || null; +} + export function getModelUpstreamId(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; const found = models?.find(m => m.id === modelId); diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 8658aba9102..14e73f29d7a 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -71,8 +71,8 @@ export const PROVIDERS = { baseUrl: "https://chatgpt.com/backend-api/codex/responses", format: "openai-responses", headers: { - "originator": "codex-cli", - "User-Agent": "codex-cli/1.0.18 (macOS; arm64)" + "originator": "codex_cli_rs", + "User-Agent": "codex_cli_rs/0.136.0" }, clientId: "app_EMoamEEZ73f0CkXaXp7hrann", tokenUrl: "https://auth.openai.com/oauth/token" diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 1bdce2b1bc7..b89417a2ad4 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -31,11 +31,26 @@ export const MEMORY_CONFIG = { proxyDispatchersMaxSize: 20, }; -// Stream stall timeout: abort if no chunk received within this duration -export const STREAM_STALL_TIMEOUT_MS = 30 * 1000; +function envPositiveInt(name, fallback) { + const raw = process.env[name]; + if (raw == null || raw === "") return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +// Stream stall timeout: abort if no upstream bytes arrive within this duration. +// Reasoning providers can legally stay silent for 60s+ while thinking, so keep +// this well above common proxy read timeouts; downstream keepalives handle proxy +// idleness separately. +export const STREAM_STALL_TIMEOUT_MS = envPositiveInt("STREAM_STALL_TIMEOUT_MS", 5 * 60 * 1000); + +// Downstream keepalive cadence. SSE uses comment events; JSON uses leading +// whitespace, which remains valid before the final JSON document. +export const STREAM_HEARTBEAT_INTERVAL_MS = envPositiveInt("STREAM_HEARTBEAT_INTERVAL_MS", 10 * 1000); +export const JSON_KEEPALIVE_INTERVAL_MS = envPositiveInt("JSON_KEEPALIVE_INTERVAL_MS", 10 * 1000); // Fetch connect timeout: abort if upstream doesn't return response headers within this duration -export const FETCH_CONNECT_TIMEOUT_MS = 20 * 1000; +export const FETCH_CONNECT_TIMEOUT_MS = 60 * 1000; // Default token limits export const DEFAULT_MAX_TOKENS = 64000; diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index adb942b1007..aaf6ede88ed 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,4 +1,5 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; +import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { dbg } from "../utils/debugLog.js"; @@ -87,9 +88,7 @@ export class BaseExecutor { } needsRefresh(credentials) { - if (!credentials.expiresAt) return false; - const expiresAtMs = new Date(credentials.expiresAt).getTime(); - return expiresAtMs - Date.now() < 5 * 60 * 1000; + return shouldRefreshCredentials(this.provider, credentials); } parseError(response, bodyText) { @@ -122,15 +121,16 @@ export class BaseExecutor { if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; - // Abort if upstream doesn't return response headers within FETCH_CONNECT_TIMEOUT_MS + // Abort if upstream doesn't return response headers within connection timeout const connectCtrl = new AbortController(); - const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), FETCH_CONNECT_TIMEOUT_MS); + const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS; + const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; try { const bodyStr = JSON.stringify(transformedBody); const fetchT0 = Date.now(); - dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${FETCH_CONNECT_TIMEOUT_MS}ms`); + dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`); const response = await proxyAwareFetch(url, { method: "POST", headers, diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index db3b634f5ad..b2916987162 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -2,6 +2,10 @@ import { createHash } from "crypto"; import { BaseExecutor } from "./base.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "../services/oauthCredentialManager.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; import { getModelUpstreamId } from "../config/providerModels.js"; @@ -212,6 +216,15 @@ export class CodexExecutor extends BaseExecutor { return this._isCompact ? `${base}/compact` : base; } + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + return refreshProviderCredentials("codex", credentials, log); + } + + needsRefresh(credentials) { + return shouldRefreshCredentials("codex", credentials); + } + /** * Prefetch remote image URLs and inline them as base64 data URIs. * Runs before execute() because Codex backend cannot fetch remote images. diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index afb77efc8f4..885e3858302 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -1,5 +1,5 @@ import { BaseExecutor } from "./base.js"; -import { PROVIDERS, resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; +import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; @@ -67,9 +67,6 @@ export class DefaultExecutor extends BaseExecutor { case "gemini": return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; default: { - if (this.provider === "xiaomi-tokenplan") { - return `${resolveXiaomiTokenplanBaseUrl(credentials)}/chat/completions`; - } const url = this.config.baseUrl; if (url?.includes("{accountId}")) { const accountId = credentials?.providerSpecificData?.accountId; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 78372cbf9ef..4a4439035de 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -15,6 +15,7 @@ import { GrokWebExecutor } from "./grok-web.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 { DefaultExecutor } from "./default.js"; const executors = { @@ -37,6 +38,7 @@ const executors = { "perplexity-web": new PerplexityWebExecutor(), "ollama-local": new OllamaLocalExecutor(), commandcode: new CommandCodeExecutor(), + "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), }; const defaultCache = new Map(); @@ -70,3 +72,4 @@ export { GrokWebExecutor } from "./grok-web.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; +export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 079f38be286..3be1f00f8a9 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -12,8 +12,8 @@ * - The request shape Qoder expects is non-trivial (chat_context with * mirrored modelConfig, business block with stable IDs, system text * hoisted out of the messages array). All ported from the reference. - * - Model identifier is one of the canonical 11 keys (auto / ultimate / - * performance / efficient / lite + 6 frontier "*model" ids); the + * - Model identifier is one of the canonical Qoder keys (auto / ultimate / + * performance / efficient / lite + frontier "*model" ids); the * translator layer feeds us "qoder/" so we strip the prefix. * - Per-model `model_config` is fetched live from /algo/api/v2/model/list * and cached. Sending the wrong block silently downgrades to a @@ -125,10 +125,9 @@ function truncate(s, n) { */ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) { const qoderKey = String(model || "").replace(/^qoder\//, ""); - if (!QODER_MODEL_MAP[qoderKey]) { - throw new Error(`Unsupported qoder model: "${qoderKey}" (received "${model}")`); - } - + + // Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP. + // This allows support for new Qoder models (e.g., qmodel_latest) without code changes. let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal }); if (!modelConfig) { // Try a forced refresh once before giving up — the cache may simply @@ -447,4 +446,5 @@ export default QoderExecutor; export const __test__ = { normalizeMessages, wrapQoderSSE, + buildQoderRequestBody, }; diff --git a/open-sse/executors/xiaomi-tokenplan.js b/open-sse/executors/xiaomi-tokenplan.js new file mode 100644 index 00000000000..259da07f6c7 --- /dev/null +++ b/open-sse/executors/xiaomi-tokenplan.js @@ -0,0 +1,19 @@ +import { DefaultExecutor } from "./default.js"; +import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; +import { getModelTargetFormat } from "../config/providerModels.js"; +import { FORMATS } from "../translator/formats.js"; + +export class XiaomiTokenplanExecutor extends DefaultExecutor { + constructor() { + super("xiaomi-tokenplan"); + } + + // Claude-native aliases route to the Anthropic-compatible messages endpoint + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials); + if (getModelTargetFormat(model, model) === FORMATS.CLAUDE) { + return `${baseUrl.replace(/\/v1\/?$/, "/anthropic/v1")}/messages`; + } + return `${baseUrl}/chat/completions`; + } +} diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 6120fec3cdd..9e0cafb6ca2 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -5,7 +5,7 @@ import { COLORS } from "../utils/stream.js"; import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; -import { getModelTargetFormat, getModelStrip, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; +import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; @@ -15,6 +15,7 @@ import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDeta import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; +import { createJsonKeepaliveResponse } from "../utils/jsonKeepalive.js"; import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; import { dedupeTools } from "../utils/toolDeduper.js"; import { injectCaveman } from "../rtk/caveman.js"; @@ -45,6 +46,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const modelTargetFormat = getModelTargetFormat(alias, model); const targetFormat = modelTargetFormat || getTargetFormat(provider); const stripList = getModelStrip(alias, model); + const upstreamModel = getModelUpstreamId(alias, model); // Inject provider-level thinking config override (only if client hasn't set) // on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort) @@ -93,16 +95,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred let toolNameMap; if (passthrough) { log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); - translatedBody = { ...body, model }; + translatedBody = { ...body, model: upstreamModel }; } else { - translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); + translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); if (!translatedBody) { trackPendingRequest(model, provider, connectionId, false, true); return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); } toolNameMap = translatedBody._toolNameMap; delete translatedBody._toolNameMap; - translatedBody.model = model; + translatedBody.model = upstreamModel; } // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). @@ -124,6 +126,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("EFFORT", `stripped unsupported effort for ${model}`); } + // 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; + } + // RTK: compress tool_result content const rtkStats = compressMessages(translatedBody, rtkEnabled); const rtkLine = formatRtkLog(rtkStats); @@ -266,15 +274,23 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider forced streaming but client wants JSON if (!clientRequestedStreaming && providerRequiresStreaming) { + const contentType = providerResponse.headers.get("content-type") || ""; + const isForcedSSE = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex"); + if (isForcedSSE) { + const resultPromise = handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog }) + .finally(() => streamController.handleComplete()); + return await createJsonKeepaliveResponse(resultPromise); + } + const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, 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 }); - streamController.handleComplete(); - return result; + const resultPromise = handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog }) + .finally(() => streamController.handleComplete()); + return await createJsonKeepaliveResponse(resultPromise); } // Streaming response — placeholder row and completion update must share one diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index b9a68a77c1e..b347218366f 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -72,12 +72,16 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source // Claude if (targetFormat === FORMATS.CLAUDE) { - if (!responseBody.content) return responseBody; + // Always translate a Claude-format body to OpenAI, even if `content` is + // missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking + // and returns `content: null`). Returning the raw body would leave the + // OpenAI client without a `choices` array and surface as a UI test error. + if (responseBody.content && !Array.isArray(responseBody.content)) return responseBody; let textContent = "", thinkingContent = ""; const toolCalls = []; - for (const block of responseBody.content) { + for (const block of (responseBody.content || [])) { if (block.type === "text") { // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```) const raw = block.text ?? ""; diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 98bc8de4789..4e4221a7e7c 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -167,7 +167,8 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr } else { const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") }; if (hasToolCalls) message.tool_calls = toolCalls; - const finishReason = hasToolCalls ? "tool_calls" : (jsonResponse.status === "completed" ? "stop" : (jsonResponse.status || "stop")); + const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done"; + const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop")); finalResp = { id: jsonResponse.id || `chatcmpl-${Date.now()}`, object: "chat.completion", diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 2a7dfc18792..e86527c3b7a 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -2,6 +2,7 @@ import { FORMATS } from "../../translator/formats.js"; import { needsTranslation } from "../../translator/index.js"; import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js"; import { pipeWithDisconnect } from "../../utils/streamHandler.js"; +import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; @@ -43,7 +44,11 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou if (onRequestSuccess) onRequestSuccess(); const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); - const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController); + + // 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; + const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; + const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, apiKey, diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 2f8edc55189..591c7262134 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -3,8 +3,8 @@ import { randomUUID } from "node:crypto"; import { nowSec } from "./_base.js"; const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; -const CODEX_USER_AGENT = "codex-imagen/0.2.6"; -const CODEX_VERSION = "0.129.0"; +const CODEX_USER_AGENT = "codex_cli_rs/0.136.0"; +const CODEX_VERSION = "0.136.0"; const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_MODEL_SUFFIX = "-image"; const CODEX_REF_DETAIL = "high"; diff --git a/open-sse/index.js b/open-sse/index.js index dde009c5d31..4b2ac3a9ceb 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -59,6 +59,14 @@ export { refreshTokenByProvider } from "./services/tokenRefresh.js"; +export { + CODEX_MAX_REFRESH_AGE_MS, + shouldRefreshCredentials, + refreshProviderCredentials, + mergeRefreshedCredentials, + mergeProviderSpecificData, +} from "./services/oauthCredentialManager.js"; + // Handlers export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js"; export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js"; diff --git a/open-sse/rtk/cavemanPrompts.js b/open-sse/rtk/cavemanPrompts.js index c2de05fd8be..0b6f6f57d2f 100644 --- a/open-sse/rtk/cavemanPrompts.js +++ b/open-sse/rtk/cavemanPrompts.js @@ -5,31 +5,74 @@ export const CAVEMAN_LEVELS = { LITE: "lite", FULL: "full", ULTRA: "ultra", + WENYAN_LITE: "wenyan-lite", + WENYAN: "wenyan", + WENYAN_ULTRA: "wenyan-ultra", }; const SHARED_BOUNDARIES = "Code blocks, file paths, commands, errors, URLs: keep exact. Security warnings, irreversible action confirmations, multi-step ordered sequences: write normal. Resume terse style after."; +const SHARED_EXAMPLES = "Not: \"Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...\" Yes: \"Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:\""; + +const SHARED_AUTO_CLARITY = "Auto-Clarity: drop caveman for security warnings, irreversible actions, multi-step sequences where fragment ambiguity risks misread, or when user repeats a question. Resume after the clear part."; + +const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure."; + 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).", "Pattern: state the thing, the action, the reason. Then next step.", + SHARED_EXAMPLES, SHARED_BOUNDARIES, - "Active every response until user asks for normal mode.", + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, ].join(" "), [CAVEMAN_LEVELS.FULL]: [ "Respond like terse caveman. All technical substance stay exact, only fluff die.", "Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries, hedging. Fragments OK. Short synonyms (big not extensive, fix not implement a solution for).", "Pattern: [thing] [action] [reason]. [next step].", + SHARED_EXAMPLES, SHARED_BOUNDARIES, - "Active every response until user asks for normal mode.", + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, ].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].", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN_LITE]: [ + "Respond semi-classical. Drop filler/hedging but keep grammar structure, classical register.", + "Use classical Chinese sentence patterns where natural. Keep English for technical terms.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN]: [ + "Respond classical Chinese (文言文). Maximum classical terseness. 80-90% character reduction.", + "Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其).", + "Keep English for code, commands, function names, API names, error strings.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN_ULTRA]: [ + "Respond extreme classical compression (文言文 ultra). Maximum compression, ultra terse.", + "Same classical rules as wenyan-full but even more compressed. One classical particle per clause.", + SHARED_EXAMPLES, SHARED_BOUNDARIES, - "Active every response until user asks for normal mode.", + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, ].join(" "), }; diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 2bd66e63511..d2dd3c8127c 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -29,6 +29,8 @@ const ALIAS_TO_PROVIDER_ID = { kimi: "kimi", minimax: "minimax", "minimax-cn": "minimax-cn", + hf: "huggingface", + huggingface: "huggingface", ds: "deepseek", deepseek: "deepseek", cmc: "commandcode", diff --git a/open-sse/services/oauthCredentialManager.js b/open-sse/services/oauthCredentialManager.js new file mode 100644 index 00000000000..466fcda95ea --- /dev/null +++ b/open-sse/services/oauthCredentialManager.js @@ -0,0 +1,151 @@ +import { + getRefreshLeadMs, + isUnrecoverableRefreshError, + refreshTokenByProvider, +} from "./tokenRefresh.js"; + +export const CODEX_MAX_REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000; + +const refreshLocks = new Map(); + +function parseTimeMs(value) { + if (value === undefined || value === null || value === "") return null; + if (typeof value === "number") { + return value < 1e12 ? value * 1000 : value; + } + + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; +} + +function toExpiresAt(expiresIn, nowMs = Date.now()) { + if (!expiresIn) return null; + return new Date(nowMs + expiresIn * 1000).toISOString(); +} + +export function getCredentialExpiryMs(credentials) { + return parseTimeMs(credentials?.expiresAt ?? credentials?.tokenExpiresAt); +} + +export function getCredentialLastRefreshMs(credentials) { + return parseTimeMs( + credentials?.lastRefreshAt ?? + credentials?.lastRefresh ?? + credentials?.providerSpecificData?.lastRefreshAt + ); +} + +export function isCodexRefreshStale(credentials, nowMs = Date.now()) { + const lastRefreshMs = getCredentialLastRefreshMs(credentials); + return !lastRefreshMs || nowMs - lastRefreshMs >= CODEX_MAX_REFRESH_AGE_MS; +} + +export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now()) { + if (!credentials) return false; + + const expiresAtMs = getCredentialExpiryMs(credentials); + if (expiresAtMs !== null && expiresAtMs - nowMs < getRefreshLeadMs(provider)) { + return true; + } + + if (provider === "codex" && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs)) { + return true; + } + + return false; +} + +export function mergeProviderSpecificData(existing, next) { + if (!next || typeof next !== "object") return existing; + return { + ...(existing || {}), + ...next, + }; +} + +export function mergeRefreshedCredentials(provider, currentCredentials, refreshedCredentials, nowMs = Date.now()) { + if (!refreshedCredentials) return null; + if (isUnrecoverableRefreshError(refreshedCredentials)) return refreshedCredentials; + + const next = {}; + const nowIso = new Date(nowMs).toISOString(); + + if (refreshedCredentials.accessToken) next.accessToken = refreshedCredentials.accessToken; + if (refreshedCredentials.apiKey) next.apiKey = refreshedCredentials.apiKey; + if (refreshedCredentials.token) next.token = refreshedCredentials.token; + + const refreshToken = refreshedCredentials.refreshToken ?? currentCredentials?.refreshToken; + if (refreshToken) next.refreshToken = refreshToken; + + const idToken = refreshedCredentials.idToken ?? currentCredentials?.idToken; + if (idToken) next.idToken = idToken; + + if (refreshedCredentials.expiresIn) { + next.expiresIn = refreshedCredentials.expiresIn; + next.expiresAt = toExpiresAt(refreshedCredentials.expiresIn, nowMs); + } else if (refreshedCredentials.expiresAt) { + next.expiresAt = refreshedCredentials.expiresAt; + } + + if (refreshedCredentials.projectId) next.projectId = refreshedCredentials.projectId; + + if (refreshedCredentials.providerSpecificData) { + next.providerSpecificData = mergeProviderSpecificData( + currentCredentials?.providerSpecificData, + refreshedCredentials.providerSpecificData + ); + } + + if (refreshedCredentials.copilotToken) next.copilotToken = refreshedCredentials.copilotToken; + if (refreshedCredentials.copilotTokenExpiresAt) { + next.copilotTokenExpiresAt = refreshedCredentials.copilotTokenExpiresAt; + } + + if ( + provider === "codex" || + next.accessToken || + next.apiKey || + next.token || + next.refreshToken || + next.copilotToken + ) { + next.lastRefreshAt = refreshedCredentials.lastRefreshAt || nowIso; + } + + return next; +} + +function getRefreshLockKey(provider, credentials) { + const stableId = + credentials?.connectionId || + credentials?.id || + credentials?.email || + credentials?.name || + credentials?.refreshToken?.slice?.(-16) || + "default"; + return `${provider}:${stableId}`; +} + +export async function withCredentialRefreshLock(provider, credentials, refreshFn) { + const key = getRefreshLockKey(provider, credentials); + const existing = refreshLocks.get(key); + if (existing) return existing; + + const pending = Promise.resolve() + .then(refreshFn) + .finally(() => { + refreshLocks.delete(key); + }); + + refreshLocks.set(key, pending); + return pending; +} + +export async function refreshProviderCredentials(provider, credentials, log) { + if (!credentials) return null; + + return withCredentialRefreshLock(provider, credentials, async () => { + const refreshed = await refreshTokenByProvider(provider, credentials, log); + return mergeRefreshedCredentials(provider, credentials, refreshed); + }); +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 63b0fdf973e..0dce4121aa6 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -277,6 +277,27 @@ export async function refreshQwenToken(refreshToken, log) { }, log); } +export function classifyOAuthRefreshError(errorText = "", status = 0) { + let parsed = null; + try { + parsed = errorText ? JSON.parse(errorText) : null; + } catch { + parsed = null; + } + + const code = parsed?.error?.code || parsed?.error || parsed?.error_code || ""; + const description = parsed?.error_description || parsed?.message || errorText || ""; + const combined = `${code} ${description}`.toLowerCase(); + const permanent = [ + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated", + "invalid_grant", + ].some((marker) => combined.includes(marker)); + + return { status, code, description, permanent }; +} + /** * Specialized refresh for Codex (OpenAI) OAuth tokens. * OpenAI uses rotating (one-time-use) refresh tokens. @@ -286,68 +307,59 @@ export async function refreshQwenToken(refreshToken, log) { export async function refreshCodexToken(refreshToken, log) { if (!refreshToken) return null; return dedupRefresh("codex", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.openai.token, { - 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.codex.clientId, - scope: "openid profile email offline_access", - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - - // Detect unrecoverable errors (token reused/expired) — Auth0 revokes whole family on retry - let errorCode = null; try { - const parsed = JSON.parse(errorText); - errorCode = parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); - } catch {} - - if ( - errorCode === "refresh_token_reused" || - errorCode === "invalid_grant" || - errorCode === "token_expired" || - errorCode === "invalid_token" - ) { - log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", { - status: response.status, - errorCode, + const response = await fetch(OAUTH_ENDPOINTS.openai.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: PROVIDERS.codex.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), }); - return { error: "unrecoverable_refresh_error", code: errorCode }; - } - log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", { - status: response.status, - error: errorText, - }); - return null; - } + if (!response.ok) { + const errorText = await response.text(); + const failure = classifyOAuthRefreshError(errorText, response.status); + if (failure.permanent) { + log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", { + status: response.status, + code: failure.code, + }); + return { error: "unrecoverable_refresh_error", code: failure.code }; + } - const tokens = await response.json(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", { + status: response.status, + error: errorText, + code: failure.code, + permanent: failure.permanent, + }); + return null; + } - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); + const tokens = await response.json(); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); - return null; - } + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + hasIdToken: !!tokens.id_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); + return null; + } }, log); } diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 277472b62c3..1441d2e3d5e 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -399,6 +399,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptio const importantModels = [ 'gemini-3-flash-agent', 'gemini-3.5-flash-low', + 'gemini-3.5-flash-extra-low', 'gemini-pro-agent', 'gemini-3.1-pro-low', 'claude-sonnet-4-6', @@ -995,6 +996,12 @@ function formatMiniMaxQuotaName(model) { const rawName = getMiniMaxModelName(model); if (!rawName) return "MiniMax"; + // M3+ shared quota pool: MiniMax reports M-series as a single wildcard + // bucket ("MiniMax-M*"). Newer responses rename it to plain "general". + // Render both as a friendly series label rather than leaking the + // asterisk or the vague "general" word to the UI. + if (rawName === "MiniMax-M*" || rawName === "general") return "M-series"; + return rawName .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") @@ -1005,6 +1012,15 @@ function formatMiniMaxQuotaName(model) { .replace(/\bHd\b/g, "HD"); } +function getMiniMaxProvidedPercent(model, snakeKey, camelKey) { + if (!model || typeof model !== "object") return null; + const raw = model[snakeKey] ?? model[camelKey]; + if (raw === null || raw === undefined) return null; + const num = Number(raw); + if (!Number.isFinite(num)) return null; + return Math.max(0, Math.min(100, num)); +} + function getMiniMaxSessionTotal(model) { return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0); } @@ -1014,7 +1030,12 @@ function getMiniMaxWeeklyTotal(model) { } function hasMiniMaxQuota(model) { - return getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0; + // Old format has real count totals; M3-era M-series buckets ship percent-only + // (count fields are 0) so accept those too. + if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true; + if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true; + if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true; + return false; } function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { @@ -1023,30 +1044,57 @@ function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endS return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); } -function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) { +function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) { const safeTotal = Math.max(0, total); const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal); const remaining = Math.max(safeTotal - used, 0); + // M-series buckets ship percent-only (count = 0). Prefer the upstream value + // when present, otherwise fall back to the computed percentage. When the + // quota is unbounded (no count) and no upstream percent is available, surface + // the percent anyway as long as it is defined. + const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal); return { used, total: safeTotal, remaining, - remainingPercentage: safeTotal > 0 ? Math.max(0, Math.min(100, (remaining / safeTotal) * 100)) : 0, + remainingPercentage, resetAt, unlimited: false, }; } -function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, resetArgs, countMeansRemaining) { +function providedPercentage(provided, remaining, total) { + if (provided !== null && provided !== undefined && Number.isFinite(provided)) { + return Math.max(0, Math.min(100, provided)); + } + return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0; +} + +function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) { const total = getTotal(model); - if (total <= 0) return; + const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel); + if (total <= 0 && providedPercent === null) return; const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0); + let effectiveTotal = total; + let effectiveCount = count; + if (total <= 0) { + // M-series bucket: API only ships *_remaining_percent (count = 0). Normalize + // to total=100. The downstream buildMiniMaxQuota treats the count as + // "used" or "remaining" depending on countMeansRemaining, so the synthetic + // count has to match that semantic — otherwise the UI flips the percentage. + effectiveTotal = 100; + const pct = providedPercent; + effectiveCount = countMeansRemaining + ? Math.round(effectiveTotal * (pct / 100)) + : Math.round(effectiveTotal * (1 - pct / 100)); + } quotas[key] = buildMiniMaxQuota( - total, - count, + effectiveTotal, + effectiveCount, getMiniMaxResetAt(model, ...resetArgs), - countMeansRemaining + countMeansRemaining, + providedPercent ); } @@ -1122,6 +1170,8 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { getMiniMaxSessionTotal, "current_interval_usage_count", "currentIntervalUsageCount", + "current_interval_remaining_percent", + "currentIntervalRemainingPercent", [capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"], countMeansRemaining ); @@ -1133,6 +1183,8 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { getMiniMaxWeeklyTotal, "current_weekly_usage_count", "currentWeeklyUsageCount", + "current_weekly_remaining_percent", + "currentWeeklyRemainingPercent", [capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"], countMeansRemaining ); diff --git a/open-sse/transformer/streamToJsonConverter.js b/open-sse/transformer/streamToJsonConverter.js index 92cb723b87c..c08acb2553b 100644 --- a/open-sse/transformer/streamToJsonConverter.js +++ b/open-sse/transformer/streamToJsonConverter.js @@ -27,7 +27,7 @@ function processSSEMessage(msg, state) { state.created = parsed.response?.created_at || state.created; } else if (eventType === "response.output_item.done") { state.items.set(parsed.output_index ?? 0, parsed.item); - } else if (eventType === "response.completed") { + } else if (eventType === "response.completed" || eventType === "response.done") { state.status = "completed"; if (parsed.response?.usage) { state.usage.input_tokens = parsed.response.usage.input_tokens || 0; diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index f1a9baae655..9988c7691e5 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -291,15 +291,35 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { return blocks; } -// Convert OpenAI tool choice to Claude format +// Convert OpenAI tool choice to Claude format. +// Claude only accepts tool_choice.type of "auto" | "any" | "tool" | "none"; +// anything else (e.g. OpenAI's "function") triggers a 400, so we never pass an +// unrecognized type through. +const CLAUDE_TOOL_CHOICE_TYPES = new Set(["auto", "any", "tool", "none"]); + function convertOpenAIToolChoice(choice) { if (!choice) return { type: "auto" }; - if (typeof choice === "object" && choice.type) return choice; - if (choice === "auto" || choice === "none") return { type: "auto" }; - if (choice === "required") return { type: "any" }; - if (typeof choice === "object" && choice.function) { - return { type: "tool", name: choice.function.name }; + + // OpenAI string forms: "auto" | "none" | "required" + if (typeof choice === "string") { + if (choice === "required") return { type: "any" }; + return { type: "auto" }; // "auto", "none", or anything unexpected } + + if (typeof choice === "object") { + // OpenAI forced tool: { type: "function", function: { name } }. + // Checked before the native pass-through below, because the OpenAI shape + // also carries a `.type` ("function") that Claude rejects. + if (choice.function?.name) { + return { type: "tool", name: choice.function.name }; + } + // Already Claude-native — only pass through types Claude actually accepts, + // so a malformed or unknown type can never leak into the upstream request. + if (CLAUDE_TOOL_CHOICE_TYPES.has(choice.type)) { + return choice; + } + } + return { type: "auto" }; } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 716ec861a54..d3a502f3e9c 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -12,22 +12,182 @@ import { KIRO_AGENTIC_SYSTEM_PROMPT } from "../../config/kiroConstants.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 === "tool") { + out.push({ role: "user", content: toolResultToText(msg.content) }); + continue; + } + + if (msg.role === "assistant") { + const parts = []; + if (Array.isArray(msg.content)) { + for (const c of msg.content) { + if (c.type === "tool_use") { + parts.push(toolCallToText(c.name, c.input)); + } else if (c.type === "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: "assistant", content: parts.filter(Boolean).join("\n") }); + continue; + } + + // User messages: replace tool_result blocks with text, keep text + images. + if (msg.role === "user" && Array.isArray(msg.content)) { + const newContent = msg.content.map(c => + c.type === "tool_result" + ? { type: "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; + } + } +} + +/** + * Safely parse JSON string, returning fallback on failure. + */ +function safeJSONParse(str, fallback) { + if (typeof str !== "string") return str ?? fallback; + try { return JSON.parse(str); } catch { return fallback; } +} + /** * Convert OpenAI messages to Kiro format - * Rules: system/tool/user -> user role, merge consecutive same roles + * Rules: system/tool/user -> user role, merge consecutive same roles. + * + * Returns { history, currentMessage }. */ function convertMessages(messages, tools, 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; - - // Image support is pre-filtered by caps in translateRequest before reaching here - const supportsImages = true; + let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -49,20 +209,23 @@ function convertMessages(messages, tools, model) { toolResults: pendingToolResults }; } - - // Add tools to first user message - if (tools && tools.length > 0 && history.length === 0) { + + // 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 @@ -77,8 +240,9 @@ function convertMessages(messages, tools, model) { } }; }); + toolsInjectedToFirstUserMsg = true; } - + history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -99,18 +263,18 @@ function convertMessages(messages, tools, model) { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; let role = msg.role; - + // Normalize: system/tool -> user if (role === "system" || role === "tool") { role = "user"; } - + // If role changes, flush pending if (role !== currentRole && currentRole !== null) { flushPending(); } currentRole = role; - + if (role === "user") { // Extract content let content = ""; @@ -121,7 +285,7 @@ function convertMessages(messages, tools, model) { for (const c of msg.content) { if (c.type === "text" || c.text) { textParts.push(c.text || ""); - } else if (supportsImages && c.type === "image_url") { + } else if (c.type === "image_url") { // OpenAI format: image_url.url with data URI const url = c.image_url?.url || ""; const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); @@ -133,7 +297,7 @@ function convertMessages(messages, tools, model) { // Kiro only supports base64 — fallback to URL text textParts.push(`[Image: ${url}]`); } - } else if (supportsImages && c.type === "image") { + } else if (c.type === "image") { // Claude format: source.type = "base64", source.media_type, source.data if (c.source?.type === "base64" && c.source?.data) { const mediaType = c.source.media_type || "image/png"; @@ -143,15 +307,15 @@ function convertMessages(messages, tools, model) { } } content = textParts.join("\n"); - + // Check for tool_result blocks const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); if (toolResultBlocks.length > 0) { toolResultBlocks.forEach(block => { - const text = Array.isArray(block.content) + const text = Array.isArray(block.content) ? block.content.map(c => c.text || "").join("\n") : (typeof block.content === "string" ? block.content : ""); - + pendingToolResults.push({ toolUseId: block.tool_use_id, status: "success", @@ -160,7 +324,7 @@ function convertMessages(messages, tools, model) { }); } } - + // Handle tool role (from normalized) if (msg.role === "tool") { const toolContent = typeof msg.content === "string" ? msg.content : ""; @@ -176,34 +340,30 @@ function convertMessages(messages, tools, model) { // Extract text content and tool uses let textContent = ""; let toolUses = []; - + if (Array.isArray(msg.content)) { const textBlocks = msg.content.filter(c => c.type === "text"); textContent = textBlocks.map(b => b.text).join("\n").trim(); - + const toolUseBlocks = msg.content.filter(c => c.type === "tool_use"); toolUses = toolUseBlocks; } else if (typeof msg.content === "string") { textContent = msg.content.trim(); } - + if (msg.tool_calls && msg.tool_calls.length > 0) { toolUses = msg.tool_calls; } - + if (textContent) { pendingAssistantContent.push(textContent); } - + // Store tool uses in last assistant message if (toolUses.length > 0) { - if (pendingAssistantContent.length === 0) { - // pendingAssistantContent.push("Call tools"); - } - // Flush to create assistant message with toolUses flushPending(); - + const lastMsg = history[history.length - 1]; if (lastMsg?.assistantResponseMessage) { lastMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { @@ -211,9 +371,7 @@ function convertMessages(messages, tools, model) { return { toolUseId: tc.id || uuidv4(), name: tc.function.name, - input: typeof tc.function.arguments === "string" - ? JSON.parse(tc.function.arguments) - : (tc.function.arguments || {}) + input: safeJSONParse(tc.function.arguments, {}) }; } else { return { @@ -224,17 +382,17 @@ function convertMessages(messages, tools, model) { } }); } - + currentRole = null; } } } - + // Flush remaining if (currentRole !== null) { flushPending(); } - + // Pop last userInputMessage as currentMessage (search from end, skip trailing assistant messages) for (let i = history.length - 1; i >= 0; i--) { if (history[i].userInputMessage) { @@ -261,6 +419,8 @@ function convertMessages(messages, tools, model) { }); // Merge consecutive user messages (Kiro requires alternating user/assistant) + // When merging, also combine userInputMessageContext fields so toolResults + // and images from the second message are not silently dropped. const mergedHistory = []; for (let i = 0; i < history.length; i++) { const current = history[i]; @@ -269,18 +429,63 @@ function convertMessages(messages, tools, model) { mergedHistory[mergedHistory.length - 1].userInputMessage) { const prev = mergedHistory[mergedHistory.length - 1]; prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; + // Merge context: combine toolResults, images, etc. + const prevCtx = prev.userInputMessage.userInputMessageContext; + const curCtx = current.userInputMessage.userInputMessageContext; + if (curCtx) { + if (!prevCtx) { + prev.userInputMessage.userInputMessageContext = curCtx; + } else { + if (curCtx.toolResults?.length > 0) { + prevCtx.toolResults = [...(prevCtx.toolResults || []), ...curCtx.toolResults]; + } + if (curCtx.tools?.length > 0) { + prevCtx.tools = [...(prevCtx.tools || []), ...curCtx.tools]; + } + } + } } else { mergedHistory.push(current); } } - // Inject tools into currentMessage AFTER cleanup - if (firstHistoryTools && currentMessage?.userInputMessage && + // When currentMessage is null (no user messages at all — edge case where + // input is only assistant messages), create a minimal currentMessage so + // tools and content can be injected. + if (!currentMessage) { + currentMessage = { + userInputMessage: { + content: "", + modelId: 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 = firstHistoryTools; + currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; } return { history: mergedHistory, currentMessage }; @@ -318,6 +523,7 @@ export function buildKiroPayload(model, body, stream, credentials) { const profileArn = credentials?.providerSpecificData?.profileArn || ""; let finalContent = currentMessage?.userInputMessage?.content || ""; + const timestamp = new Date().toISOString(); // Build the system-prompt prefix that goes ABOVE the user message body. diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 0cf79cbd129..e500e4c7952 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -490,7 +490,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { } // Response completed - if (eventType === "response.completed") { + if (eventType === "response.completed" || eventType === "response.done") { // Extract usage from response.completed event const responseUsage = data.response?.usage; if (responseUsage && typeof responseUsage === "object") { diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 2c9d1f263fb..688573ab486 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -35,13 +35,16 @@ export function cloakClaudeTools(body) { const tools = body.tools; if (!tools || tools.length === 0) return { body, toolNameMap: null }; + const suffix = (name) => `${name}${CLAUDE_TOOL_SUFFIX}`; const toolNameMap = new Map(); + const clientToolNames = new Set(); const clientDeclarations = []; // All client tools get renamed with suffix for (const tool of tools) { - const suffixed = `${tool.name}${CLAUDE_TOOL_SUFFIX}`; + const suffixed = suffix(tool.name); toolNameMap.set(suffixed, tool.name); + clientToolNames.add(tool.name); clientDeclarations.push({ ...tool, name: suffixed }); } @@ -51,17 +54,27 @@ export function cloakClaudeTools(body) { // Rename tool_use in message history (all client tools get suffix) const renamedMessages = body.messages?.map(msg => { if (!Array.isArray(msg.content)) return msg; - const renamedContent = msg.content.map(block => { - if (block.type === "tool_use") { - return { ...block, name: `${block.name}${CLAUDE_TOOL_SUFFIX}` }; - } - return block; - }); + const renamedContent = msg.content.map(block => + block.type === "tool_use" ? { ...block, name: suffix(block.name) } : block + ); return { ...msg, content: renamedContent }; }); + const cloakedBody = { ...body, tools: allTools, messages: renamedMessages || body.messages }; + + // A forced tool_choice ({ type: "tool", name }) must point at the suffixed + // tool name, otherwise Claude rejects it: "Tool '' not found in provided tools". + // Only rewrite when the choice targets one of the client tools we actually + // renamed — never a decoy/built-in name (those are sent unsuffixed). + if ( + body.tool_choice?.type === "tool" && + clientToolNames.has(body.tool_choice.name) + ) { + cloakedBody.tool_choice = { ...body.tool_choice, name: suffix(body.tool_choice.name) }; + } + return { - body: { ...body, tools: allTools, messages: renamedMessages || body.messages }, + body: cloakedBody, toolNameMap: toolNameMap.size > 0 ? toolNameMap : null }; } diff --git a/open-sse/utils/jsonKeepalive.js b/open-sse/utils/jsonKeepalive.js new file mode 100644 index 00000000000..58e76592f41 --- /dev/null +++ b/open-sse/utils/jsonKeepalive.js @@ -0,0 +1,99 @@ +import { JSON_KEEPALIVE_INTERVAL_MS } from "../config/runtimeConfig.js"; + +const encoder = new TextEncoder(); +const KEEPALIVE_CHUNK = " \n"; + +async function writeResponseBody(controller, response) { + if (!response?.body) { + controller.enqueue(encoder.encode("null")); + return; + } + + const reader = response.body.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) controller.enqueue(value); + } + } finally { + reader.releaseLock(); + } +} + +/** + * Return JSON response immediately and keep the downstream proxy alive while + * expensive non-stream conversion buffers upstream. Leading JSON whitespace is + * valid, so clients still parse the final document normally. + */ +export function createJsonKeepaliveResponse(resultPromise, { intervalMs = JSON_KEEPALIVE_INTERVAL_MS } = {}) { + if (intervalMs <= 0) return resultPromise; + + const settled = resultPromise.then( + (result) => ({ settled: true, result }), + (error) => ({ settled: true, error }) + ); + + return Promise.race([ + settled, + new Promise((resolve) => setTimeout(() => resolve({ settled: false }), intervalMs)) + ]).then((first) => { + if (first.settled) { + if (first.error) throw first.error; + return first.result; + } + + return createStreamingJsonKeepaliveResponse(settled, intervalMs); + }); +} + +function createStreamingJsonKeepaliveResponse(settledPromise, intervalMs) { + let timer = null; + const clearTimer = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(KEEPALIVE_CHUNK)); + timer = setInterval(() => { + try { + controller.enqueue(encoder.encode(KEEPALIVE_CHUNK)); + } catch { + clearTimer(); + } + }, intervalMs); + + settledPromise.then(async ({ result, error }) => { + clearTimer(); + if (error) throw error; + await writeResponseBody(controller, result?.response); + controller.close(); + }).catch((err) => { + clearTimer(); + const message = err?.message || "Failed to build JSON response"; + controller.enqueue(encoder.encode(JSON.stringify({ error: { message, type: "server_error", code: "internal_server_error" } }))); + controller.close(); + }); + }, + cancel() { + clearTimer(); + } + }); + + return { + success: true, + response: new Response(stream, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*" + } + }) + }; +} diff --git a/open-sse/utils/reasoningContentInjector.js b/open-sse/utils/reasoningContentInjector.js index 5d9f238f82c..a6829b724b7 100644 --- a/open-sse/utils/reasoningContentInjector.js +++ b/open-sse/utils/reasoningContentInjector.js @@ -1,4 +1,4 @@ -// Some thinking-mode providers (DeepSeek, Kimi, ...) require reasoning_content +// Some thinking-mode providers (DeepSeek, Kimi, MiniMax, ...) require reasoning_content // to be echoed back on assistant messages. Clients in OpenAI format don't send it, // so we inject a non-empty placeholder to satisfy upstream validation. @@ -6,7 +6,9 @@ const PLACEHOLDER = " "; // Provider-level rules: keyed by executor.provider const PROVIDER_RULES = { - deepseek: { scope: "all" } + deepseek: { scope: "all" }, + minimax: { scope: "all" }, + "minimax-cn": { scope: "all" } }; // Model-level rules: matched by predicate against model id diff --git a/open-sse/utils/responsesStreamHelpers.js b/open-sse/utils/responsesStreamHelpers.js new file mode 100644 index 00000000000..6f90a0c13fd --- /dev/null +++ b/open-sse/utils/responsesStreamHelpers.js @@ -0,0 +1,49 @@ +// Helpers for OpenAI Responses API streaming termination + event framing +import { FORMATS } from "../translator/formats.js"; +import { formatSSE } from "./streamHelpers.js"; + +// Responses API events that signal the stream has reached a terminal state +const OPENAI_RESPONSES_TERMINAL_EVENTS = new Set([ + "response.completed", + "response.failed", + "error" +]); + +export function getOpenAIResponsesEventName(eventName, chunk) { + if (eventName) return eventName; + if (chunk && typeof chunk.type === "string") return chunk.type; + return null; +} + +export function isOpenAIResponsesTerminalEvent(eventName, chunk) { + const type = getOpenAIResponsesEventName(eventName, chunk); + if (OPENAI_RESPONSES_TERMINAL_EVENTS.has(type)) return true; + const status = chunk?.response?.status; + return status === "completed" || status === "failed"; +} + +const sharedEncoder = new TextEncoder(); + +// Encoded response.failed + [DONE] payload for aborted/stalled Responses passthrough streams +export function buildAbortedResponsesTerminalBytes() { + return sharedEncoder.encode(`${formatIncompleteOpenAIResponsesStreamFailure()}data: [DONE]\n\n`); +} + +// Synthesize a response.failed event for streams that close without a terminal event +export function formatIncompleteOpenAIResponsesStreamFailure() { + return formatSSE({ + event: "response.failed", + data: { + type: "response.failed", + response: { + id: `resp_${Date.now()}`, + status: "failed", + error: { + type: "stream_error", + code: "stream_disconnected", + message: "stream closed before response.completed" + } + } + } + }, FORMATS.OPENAI_RESPONSES); +} diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 721ba7d8b2d..beaa62eaff9 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -3,7 +3,9 @@ import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; +import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; +import { STREAM_HEARTBEAT_INTERVAL_MS } from "../config/runtimeConfig.js"; export { COLORS, formatSSE }; @@ -17,7 +19,6 @@ const sharedEncoder = new TextEncoder(); // null, 0 tokens). Emitting an SSE comment line on a timer keeps the connection // active. Comment lines (": ...") are ignored by every spec-compliant SSE // client (Anthropic/OpenAI SDKs included), so this is invisible to callers. -const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; const HEARTBEAT_COMMENT = ": 9router-keepalive\n\n"; /** @@ -55,7 +56,7 @@ export function createSSEStream(options = {}) { body = null, onStreamComplete = null, apiKey = null, - heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS + heartbeatIntervalMs = STREAM_HEARTBEAT_INTERVAL_MS } = options; let buffer = ""; @@ -106,6 +107,10 @@ export function createSSEStream(options = {}) { } }, heartbeatIntervalMs); }; + const resetHeartbeat = (controller) => { + stopHeartbeat(); + startHeartbeat(controller); + }; let finalized = false; const finalizeOnce = (interruptedReason = null) => { @@ -122,6 +127,11 @@ export function createSSEStream(options = {}) { ); }; + // Track Responses API event framing for same-format passthrough (codex) + let currentOpenAIResponsesEvent = null; + let openAIResponsesTerminalSeen = false; + let openAIResponsesDoneSent = false; + return new TransformStream({ start(controller) { // Begin the idle keepalive immediately — the silent gap that kills the @@ -131,10 +141,10 @@ export function createSSEStream(options = {}) { transform(chunk, controller) { if (!ttftAt) ttftAt = Date.now(); - // First real token arrived — the connection is no longer idle, so the - // keepalive has done its job. Stop it so we never interleave comment - // lines with genuine SSE data. - stopHeartbeat(); + // Reset the idle keepalive after every upstream chunk. SSE comments are + // legal between events and keep Cloudflare/nginx from seeing silence + // during later thinking gaps. + resetHeartbeat(controller); const text = decoder.decode(chunk, { stream: true }); buffer += text; reqLogger?.appendProviderChunk?.(text); @@ -152,6 +162,11 @@ export function createSSEStream(options = {}) { } } + // Capture Responses API event name to preserve framing in same-format passthrough + if (mode === STREAM_MODE.TRANSLATE && targetFormat === FORMATS.OPENAI_RESPONSES && trimmed.startsWith("event:")) { + currentOpenAIResponsesEvent = trimmed.slice(6).trim(); + } + // Passthrough mode: normalize and forward if (mode === STREAM_MODE.PASSTHROUGH) { let output; @@ -243,12 +258,33 @@ export function createSSEStream(options = {}) { const parsed = parseSSELine(trimmed, targetFormat); if (!parsed) continue; + // Responses API same-format passthrough: preserve event framing + track terminal state + const isOpenAIResponsesStream = targetFormat === FORMATS.OPENAI_RESPONSES; + const keepsOpenAIResponsesFormat = isOpenAIResponsesStream && sourceFormat === FORMATS.OPENAI_RESPONSES; + const openAIResponsesEventName = isOpenAIResponsesStream + ? getOpenAIResponsesEventName(currentOpenAIResponsesEvent, parsed) + : null; + + if (isOpenAIResponsesStream && isOpenAIResponsesTerminalEvent(openAIResponsesEventName, parsed)) { + openAIResponsesTerminalSeen = true; + } + // For Ollama: done=true is the final chunk with finish_reason/usage, must translate // For other formats: done=true is the [DONE] sentinel, skip if (parsed && parsed.done && targetFormat !== FORMATS.OLLAMA) { + // Synthesize response.failed if the Responses stream never sent a terminal event + if (keepsOpenAIResponsesFormat && !openAIResponsesTerminalSeen) { + const failedOutput = formatIncompleteOpenAIResponsesStreamFailure(); + reqLogger?.appendConvertedChunk?.(failedOutput); + controller.enqueue(sharedEncoder.encode(failedOutput)); + openAIResponsesTerminalSeen = true; + sseEmittedCount++; + } + const output = "data: [DONE]\n\n"; reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); + if (keepsOpenAIResponsesFormat) openAIResponsesDoneSent = true; continue; } @@ -293,6 +329,18 @@ export function createSSEStream(options = {}) { const extracted = extractUsage(parsed); if (extracted) state.usage = extracted; // Keep original usage for logging + // Responses same-format passthrough: re-emit with original event framing + if (keepsOpenAIResponsesFormat && openAIResponsesEventName) { + const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + currentOpenAIResponsesEvent = null; + sseEmittedCount++; + continue; + } + + currentOpenAIResponsesEvent = null; + // Translate: targetFormat -> openai -> sourceFormat const translated = translateResponse(targetFormat, sourceFormat, parsed, state); @@ -306,6 +354,7 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { + if (item === null || item === undefined) continue; // Filter empty chunks if (!hasValuableContent(item, sourceFormat)) { continue; // Skip this empty chunk @@ -386,6 +435,7 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { + if (item === null || item === undefined) continue; const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); @@ -405,15 +455,27 @@ export function createSSEStream(options = {}) { if (flushed?.length > 0) { for (const item of flushed) { + if (item === null || item === undefined) continue; const output = formatSSE(item, sourceFormat); reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); } } - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(sharedEncoder.encode(doneOutput)); + // Synthesize response.failed if a Responses passthrough stream never reached a terminal event + const keepsOpenAIResponsesFormat = targetFormat === FORMATS.OPENAI_RESPONSES && sourceFormat === FORMATS.OPENAI_RESPONSES; + if (keepsOpenAIResponsesFormat && !openAIResponsesTerminalSeen) { + const failedOutput = formatIncompleteOpenAIResponsesStreamFailure(); + reqLogger?.appendConvertedChunk?.(failedOutput); + controller.enqueue(sharedEncoder.encode(failedOutput)); + openAIResponsesTerminalSeen = true; + } + + if (!keepsOpenAIResponsesFormat || !openAIResponsesDoneSent) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + } if (!hasValidUsage(state?.usage) && totalContentLength > 0) { state.usage = estimateUsage(body, totalContentLength, sourceFormat); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 567b59acf0e..35ef5e4ae26 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -94,13 +94,25 @@ export function createStreamController({ onDisconnect, onError, log, provider, m * for long periods while raw bytes still flow (e.g. Kiro EventStream * binary frames buffering, Claude reasoning streams). */ -export function createDisconnectAwareStream(transformStream, streamController) { +export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); + let terminalEmitted = false; + + // Emit a synthesized terminal payload (e.g. Responses response.failed + [DONE]) once + const emitTerminal = (controller) => { + if (terminalEmitted || !onAbortTerminal) return; + terminalEmitted = true; + try { + const bytes = onAbortTerminal(); + if (bytes) controller.enqueue(bytes); + } catch { /* best-effort terminal */ } + }; return new ReadableStream({ async pull(controller) { if (!streamController.isConnected()) { + emitTerminal(controller); controller.close(); return; } @@ -135,17 +147,16 @@ export function createDisconnectAwareStream(transformStream, streamController) { code === "EPIPE" || code === "UND_ERR_SOCKET"; - if (!wasConnected || isNetworkClose) { - try { + // Graceful close on network/abort, or when a structured terminal is available + // (Responses passthrough prefers response.failed + [DONE] over a raw transport error) + try { + if (!wasConnected || isNetworkClose || onAbortTerminal) { + emitTerminal(controller); controller.close(); - } catch (e) { - // Stream might already be closed or cancelled - } - } else { - try { + } else { controller.error(error); - } catch (e) { /* already closed */ } - } + } + } catch (e) { /* already closed or cancelled */ } } }, @@ -173,7 +184,7 @@ export function createDisconnectAwareStream(transformStream, streamController) { * @param {TransformStream} transformStream - Transform stream for SSE * @param {object} streamController - Stream controller from createStreamController */ -export function pipeWithDisconnect(providerResponse, transformStream, streamController) { +export function pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal = null) { let stallTimer = null; let chunkCount = 0; let totalBytes = 0; @@ -232,7 +243,8 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont return createDisconnectAwareStream( { readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, - wrappedController + wrappedController, + onAbortTerminal ); } diff --git a/package.json b/package.json index 289565de72b..f17659aedf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.66", + "version": "0.4.71", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/i18n/literals/ar.json b/public/i18n/literals/ar.json index 7e8e955c351..32b72f8b9f7 100644 --- a/public/i18n/literals/ar.json +++ b/public/i18n/literals/ar.json @@ -190,5 +190,6 @@ "Sudo Password": "كلمة مرور Sudo", "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 محلية لإعادة توجيه الطلبات إلى مزوديك. قد ينتهك شروط الخدمة → خطر حظر الحساب. الاستخدام على مسؤوليتك الخاصة." + "⚠️ 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 محلية لإعادة توجيه الطلبات إلى مزوديك. قد ينتهك شروط الخدمة → خطر حظر الحساب. الاستخدام على مسؤوليتك الخاصة.", + "Endpoint is exposed without an API key.": "نقطة النهاية مكشوفة بدون مفتاح API." } diff --git a/public/i18n/literals/bn.json b/public/i18n/literals/bn.json index 1a67af92623..ef71ddf8d5a 100644 --- a/public/i18n/literals/bn.json +++ b/public/i18n/literals/bn.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo পাসওয়ার্ড", "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 স্থানীয় CA এর মাধ্যমে IDE টুলগুলির (Antigravity, GitHub Copilot, Kiro) HTTPS ট্রাফিক ইন্টারসেপ্ট করে আপনার প্রদানকারীদের কাছে অনুরোধ পুনঃনির্দেশ করতে। ToS লঙ্ঘন করতে পারে → অ্যাকাউন্ট নিষিদ্ধ ঝুঁকি। নিজের ঝুঁকিতে ব্যবহার করুন।" + "⚠️ 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.": "এপিআই কী ছাড়াই এন্ডপয়েন্ট উন্মুক্ত।" } diff --git a/public/i18n/literals/cs.json b/public/i18n/literals/cs.json index 2d9eccfe44f..ed0d991ac96 100644 --- a/public/i18n/literals/cs.json +++ b/public/i18n/literals/cs.json @@ -190,5 +190,6 @@ "Sudo Password": "Heslo sudo", "Click to add, click again to remove. Changes are saved automatically.": "Kliknutím přidáte, dalším kliknutím odeberete. Změny se ukládají automaticky.", "⚠️ 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.": "⚠️ Upozornění na riziko: Tento poskytovatel používá předplatné/OAuth relaci, která není oficiálně licencována pro použití přes proxy/router. Účet může být omezen nebo zablokován. Používejte na vlastní riziko.", - "⚠️ 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 zachytává HTTPS provoz IDE nástrojů (Antigravity, GitHub Copilot, Kiro) přes místní CA pro přesměrování požadavků na vaše poskytovatele. Může porušit ToS → riziko zákazu účtu. Používejte na vlastní riziko." + "⚠️ 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 zachytává HTTPS provoz IDE nástrojů (Antigravity, GitHub Copilot, Kiro) přes místní CA pro přesměrování požadavků na vaše poskytovatele. Může porušit ToS → riziko zákazu účtu. Používejte na vlastní riziko.", + "Endpoint is exposed without an API key.": "Koncový bod je vystaven bez API klíče." } diff --git a/public/i18n/literals/da.json b/public/i18n/literals/da.json index ef8d6a9596a..c81bbe79e20 100644 --- a/public/i18n/literals/da.json +++ b/public/i18n/literals/da.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-adgangskode", "Click to add, click again to remove. Changes are saved automatically.": "Klik for at tilføje, klik igen for at fjerne. Ændringer gemmes automatisk.", "⚠️ 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.": "⚠️ Risikomeddelelse: Denne udbyder bruger et abonnement/OAuth-session, der ikke er officielt licenseret til proxy/router-brug. Kontoen kan blive begrænset eller forbudt. Brug på eget ansvar.", - "⚠️ 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 opfanger HTTPS-trafik fra IDE-værktøjer (Antigravity, GitHub Copilot, Kiro) via lokal CA for at omdirigere anmodninger til dine udbydere. Kan overtræde ToS → risiko for kontoforbud. Brug på eget ansvar." + "⚠️ 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 opfanger HTTPS-trafik fra IDE-værktøjer (Antigravity, GitHub Copilot, Kiro) via lokal CA for at omdirigere anmodninger til dine udbydere. Kan overtræde ToS → risiko for kontoforbud. Brug på eget ansvar.", + "Endpoint is exposed without an API key.": "Endpointet er eksponeret uden en API-nøgle." } diff --git a/public/i18n/literals/de.json b/public/i18n/literals/de.json index 6c37d13b2ac..57bf3ef39de 100644 --- a/public/i18n/literals/de.json +++ b/public/i18n/literals/de.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-Passwort", "Click to add, click again to remove. Changes are saved automatically.": "Klicken zum Hinzufügen, erneut klicken zum Entfernen. Änderungen werden automatisch gespeichert.", "⚠️ 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.": "⚠️ Risikohinweis: Dieser Anbieter verwendet eine Abonnement-/OAuth-Sitzung, die nicht offiziell für die Proxy-/Router-Nutzung lizenziert ist. Das Konto kann eingeschränkt oder gesperrt werden. Nutzung auf eigene Gefahr.", - "⚠️ 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 fängt HTTPS-Verkehr von IDE-Tools (Antigravity, GitHub Copilot, Kiro) über lokale CA ab, um Anfragen an Ihre Anbieter umzuleiten. Kann gegen ToS verstoßen → Risiko der Kontosperrung. Nutzung auf eigene Gefahr." + "⚠️ 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 fängt HTTPS-Verkehr von IDE-Tools (Antigravity, GitHub Copilot, Kiro) über lokale CA ab, um Anfragen an Ihre Anbieter umzuleiten. Kann gegen ToS verstoßen → Risiko der Kontosperrung. Nutzung auf eigene Gefahr.", + "Endpoint is exposed without an API key.": "Der Endpunkt ist ohne API-Schlüssel offengelegt." } diff --git a/public/i18n/literals/el.json b/public/i18n/literals/el.json index 3ebde7364e6..bfbf8888651 100644 --- a/public/i18n/literals/el.json +++ b/public/i18n/literals/el.json @@ -190,5 +190,6 @@ "Sudo Password": "Κωδικός πρόσβασης Sudo", "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 που δεν έχει επίσημη άδεια για χρήση μέσω proxy/router. Ο λογαριασμός ενδέχεται να περιοριστεί ή να αποκλειστεί. Χρήση με δική σας ευθύνη.", - "⚠️ 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 → κίνδυνος αποκλεισμού λογαριασμού. Χρήση με δική σας ευθύνη." + "⚠️ 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." } diff --git a/public/i18n/literals/es.json b/public/i18n/literals/es.json index fa61ee6a63a..69d71e8fcfb 100644 --- a/public/i18n/literals/es.json +++ b/public/i18n/literals/es.json @@ -190,5 +190,6 @@ "Sudo Password": "Contraseña de sudo", "Click to add, click again to remove. Changes are saved automatically.": "Haz clic para agregar, haz clic de nuevo para eliminar. Los cambios se guardan automáticamente.", "⚠️ 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 Riesgo: Este proveedor usa una sesión de suscripción/OAuth no licenciada oficialmente para uso de proxy/router. La cuenta puede ser restringida o baneada. Use bajo su propio riesgo.", - "⚠️ 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 el tráfico HTTPS de herramientas IDE (Antigravity, GitHub Copilot, Kiro) mediante CA local para redirigir solicitudes a sus proveedores. Puede violar los ToS → riesgo de baneo de cuenta. Use bajo su propio riesgo." + "⚠️ 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 el tráfico HTTPS de herramientas IDE (Antigravity, GitHub Copilot, Kiro) mediante CA local para redirigir solicitudes a sus proveedores. Puede violar los ToS → riesgo de baneo de cuenta. Use bajo su propio riesgo.", + "Endpoint is exposed without an API key.": "El endpoint está expuesto sin una clave de API." } diff --git a/public/i18n/literals/fi.json b/public/i18n/literals/fi.json index 9eaaaf51553..dc8b116c317 100644 --- a/public/i18n/literals/fi.json +++ b/public/i18n/literals/fi.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-salasana", "Click to add, click again to remove. Changes are saved automatically.": "Napsauta lisätäksesi, napsauta uudelleen poistaaksesi. Muutokset tallennetaan automaattisesti.", "⚠️ 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.": "⚠️ Riski-ilmoitus: Tämä palveluntarjoaja käyttää tilaus-/OAuth-istuntoa, jota ei ole virallisesti lisensoitu välityspalvelin-/reititinkäyttöön. Tili voidaan rajoittaa tai estää. Käyttö omalla vastuulla.", - "⚠️ 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 sieppaa IDE-työkalujen (Antigravity, GitHub Copilot, Kiro) HTTPS-liikennettä paikallisen CA:n kautta uudelleenohjatakseen pyyntöjä palveluntarjoajillesi. Voi rikkoa ToS:ää → tilin estoriski. Käyttö omalla vastuulla." + "⚠️ 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 sieppaa IDE-työkalujen (Antigravity, GitHub Copilot, Kiro) HTTPS-liikennettä paikallisen CA:n kautta uudelleenohjatakseen pyyntöjä palveluntarjoajillesi. Voi rikkoa ToS:ää → tilin estoriski. Käyttö omalla vastuulla.", + "Endpoint is exposed without an API key.": "Päätepiste on alttiina ilman API-avainta." } diff --git a/public/i18n/literals/fr.json b/public/i18n/literals/fr.json index d96b1066a92..bbf8854a494 100644 --- a/public/i18n/literals/fr.json +++ b/public/i18n/literals/fr.json @@ -190,5 +190,6 @@ "Sudo Password": "Mot de passe sudo", "Click to add, click again to remove. Changes are saved automatically.": "Cliquez pour ajouter, cliquez à nouveau pour supprimer. Les modifications sont enregistrées automatiquement.", "⚠️ 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.": "⚠️ Avis de risque : Ce fournisseur utilise une session d'abonnement/OAuth non officiellement autorisée pour une utilisation proxy/routeur. Le compte peut être restreint ou banni. Utilisez à vos propres risques.", - "⚠️ 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 intercepte le trafic HTTPS des outils IDE (Antigravity, GitHub Copilot, Kiro) via une CA locale pour rediriger les requêtes vers vos fournisseurs. Peut violer les CGU → risque de bannissement de compte. Utilisez à vos propres risques." + "⚠️ 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 intercepte le trafic HTTPS des outils IDE (Antigravity, GitHub Copilot, Kiro) via une CA locale pour rediriger les requêtes vers vos fournisseurs. Peut violer les CGU → risque de bannissement de compte. Utilisez à vos propres risques.", + "Endpoint is exposed without an API key.": "Le point de terminaison est exposé sans clé API." } diff --git a/public/i18n/literals/he.json b/public/i18n/literals/he.json index 192851884c2..c144d01b832 100644 --- a/public/i18n/literals/he.json +++ b/public/i18n/literals/he.json @@ -190,5 +190,6 @@ "Sudo Password": "סיסמת Sudo", "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 מקומי כדי להפנות בקשות לספקים שלך. עלול להפר את תנאי השירות → סיכון חסימת חשבון. השימוש על אחריותך." + "⚠️ 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 מקומי כדי להפנות בקשות לספקים שלך. עלול להפר את תנאי השירות → סיכון חסימת חשבון. השימוש על אחריותך.", + "Endpoint is exposed without an API key.": "נקודת הקצה חשופה ללא מפתח API." } diff --git a/public/i18n/literals/hi.json b/public/i18n/literals/hi.json index 11a0bee0a27..2fc6b338b90 100644 --- a/public/i18n/literals/hi.json +++ b/public/i18n/literals/hi.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo पासवर्ड", "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 स्थानीय CA के माध्यम से IDE टूल्स (Antigravity, GitHub Copilot, Kiro) के HTTPS ट्रैफिक को इंटरसेप्ट करता है ताकि अनुरोधों को आपके प्रदाताओं पर पुनर्निर्देशित किया जा सके। ToS का उल्लंघन हो सकता है → खाता बैन का जोखिम। अपने जोखिम पर उपयोग करें।" + "⚠️ 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 कुंजी के उजागर है।" } diff --git a/public/i18n/literals/hu.json b/public/i18n/literals/hu.json index a6768bb864f..8e3392914f6 100644 --- a/public/i18n/literals/hu.json +++ b/public/i18n/literals/hu.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo jelszó", "Click to add, click again to remove. Changes are saved automatically.": "Kattintson a hozzáadáshoz, kattintson újra az eltávolításhoz. A változtatások automatikusan mentésre kerülnek.", "⚠️ 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.": "⚠️ Kockázati figyelmeztetés: Ez a szolgáltató olyan előfizetést/OAuth munkamenetet használ, amely hivatalosan nincs proxy/router használatra engedélyezve. A fiók korlátozható vagy letiltható. Saját felelősségre használja.", - "⚠️ 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.": "⚠️ A MITM elfogja az IDE eszközök (Antigravity, GitHub Copilot, Kiro) HTTPS forgalmát helyi CA-n keresztül, hogy átirányítsa a kéréseket a szolgáltatóidhoz. Megsértheti a ToS-t → fiók letiltási kockázat. Saját felelősségre használja." + "⚠️ 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.": "⚠️ A MITM elfogja az IDE eszközök (Antigravity, GitHub Copilot, Kiro) HTTPS forgalmát helyi CA-n keresztül, hogy átirányítsa a kéréseket a szolgáltatóidhoz. Megsértheti a ToS-t → fiók letiltási kockázat. Saját felelősségre használja.", + "Endpoint is exposed without an API key.": "A végpont API-kulcs nélkül van kitéve." } diff --git a/public/i18n/literals/id.json b/public/i18n/literals/id.json index 2ffbfa641cb..3e5097aa7d4 100644 --- a/public/i18n/literals/id.json +++ b/public/i18n/literals/id.json @@ -190,5 +190,6 @@ "Sudo Password": "Kata Sandi Sudo", "Click to add, click again to remove. Changes are saved automatically.": "Klik untuk menambah, klik lagi untuk menghapus. Perubahan disimpan secara otomatis.", "⚠️ 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.": "⚠️ Pemberitahuan Risiko: Penyedia ini menggunakan sesi langganan/OAuth yang tidak dilisensikan secara resmi untuk penggunaan proxy/router. Akun mungkin dibatasi atau diblokir. Gunakan dengan risiko Anda sendiri.", - "⚠️ 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 mencegat lalu lintas HTTPS alat IDE (Antigravity, GitHub Copilot, Kiro) melalui CA lokal untuk mengalihkan permintaan ke penyedia Anda. Mungkin melanggar ToS → risiko ban akun. Gunakan dengan risiko Anda sendiri." + "⚠️ 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 mencegat lalu lintas HTTPS alat IDE (Antigravity, GitHub Copilot, Kiro) melalui CA lokal untuk mengalihkan permintaan ke penyedia Anda. Mungkin melanggar ToS → risiko ban akun. Gunakan dengan risiko Anda sendiri.", + "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API." } diff --git a/public/i18n/literals/it.json b/public/i18n/literals/it.json index 245f218732d..7f684e8344a 100644 --- a/public/i18n/literals/it.json +++ b/public/i18n/literals/it.json @@ -190,5 +190,6 @@ "Sudo Password": "Password Sudo", "Click to add, click again to remove. Changes are saved automatically.": "Clicca per aggiungere, clicca di nuovo per rimuovere. Le modifiche vengono salvate 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.": "⚠️ Avviso di Rischio: Questo provider utilizza una sessione abbonamento/OAuth non ufficialmente autorizzata per l'uso proxy/router. L'account potrebbe essere limitato o bannato. Usa a tuo rischio.", - "⚠️ 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 intercetta il traffico HTTPS degli strumenti IDE (Antigravity, GitHub Copilot, Kiro) tramite CA locale per reindirizzare le richieste ai tuoi provider. Può violare i ToS → rischio ban account. Usa a tuo rischio." + "⚠️ 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 intercetta il traffico HTTPS degli strumenti IDE (Antigravity, GitHub Copilot, Kiro) tramite CA locale per reindirizzare le richieste ai tuoi provider. Può violare i ToS → rischio ban account. Usa a tuo rischio.", + "Endpoint is exposed without an API key.": "L'endpoint è esposto senza una chiave API." } diff --git a/public/i18n/literals/ja.json b/public/i18n/literals/ja.json index 4069c20e600..e448c685fba 100644 --- a/public/i18n/literals/ja.json +++ b/public/i18n/literals/ja.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudoパスワード", "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はローカルCAを介してIDEツール (Antigravity, GitHub Copilot, Kiro) のHTTPSトラフィックを傍受し、リクエストをプロバイダーにリダイレクトします。ToS違反の可能性 → アカウントBANリスク。自己責任でご使用ください。" + "⚠️ 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違反の可能性 → アカウントBANリスク。自己責任でご使用ください。", + "Endpoint is exposed without an API key.": "API キーなしでエンドポイントが公開されています。" } diff --git a/public/i18n/literals/ko.json b/public/i18n/literals/ko.json index 784901884b3..1edb094ebfd 100644 --- a/public/i18n/literals/ko.json +++ b/public/i18n/literals/ko.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo 암호", "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은 로컬 CA를 통해 IDE 도구(Antigravity, GitHub Copilot, Kiro)의 HTTPS 트래픽을 가로채 요청을 공급자로 리다이렉트합니다. ToS 위반 가능성 → 계정 차단 위험. 사용에 대한 책임은 본인에게 있습니다." + "⚠️ 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 키 없이 엔드포인트가 노출되어 있습니다." } diff --git a/public/i18n/literals/nl.json b/public/i18n/literals/nl.json index 6db8aec0fe3..2eab85baf6d 100644 --- a/public/i18n/literals/nl.json +++ b/public/i18n/literals/nl.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-wachtwoord", "Click to add, click again to remove. Changes are saved automatically.": "Klik om toe te voegen, klik opnieuw om te verwijderen. Wijzigingen worden automatisch opgeslagen.", "⚠️ 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.": "⚠️ Risicokennisgeving: Deze provider gebruikt een abonnement/OAuth-sessie die niet officieel is gelicentieerd voor proxy/router-gebruik. Account kan worden beperkt of verbannen. Gebruik op eigen risico.", - "⚠️ 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 onderschept HTTPS-verkeer van IDE-tools (Antigravity, GitHub Copilot, Kiro) via lokale CA om verzoeken om te leiden naar uw providers. Kan ToS schenden → risico op accountban. Gebruik op eigen risico." + "⚠️ 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 onderschept HTTPS-verkeer van IDE-tools (Antigravity, GitHub Copilot, Kiro) via lokale CA om verzoeken om te leiden naar uw providers. Kan ToS schenden → risico op accountban. Gebruik op eigen risico.", + "Endpoint is exposed without an API key.": "Het eindpunt is blootgesteld zonder API-sleutel." } diff --git a/public/i18n/literals/no.json b/public/i18n/literals/no.json index d359925099a..e3f521556eb 100644 --- a/public/i18n/literals/no.json +++ b/public/i18n/literals/no.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-passord", "Click to add, click again to remove. Changes are saved automatically.": "Klikk for å legge til, klikk igjen for å fjerne. Endringer lagres automatisk.", "⚠️ 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.": "⚠️ Risikovarsel: Denne leverandøren bruker en abonnements-/OAuth-økt som ikke er offisielt lisensiert for proxy-/ruterbruk. Kontoen kan bli begrenset eller utestengt. Bruk på eget ansvar.", - "⚠️ 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 avskjærer HTTPS-trafikk fra IDE-verktøy (Antigravity, GitHub Copilot, Kiro) via lokal CA for å omdirigere forespørsler til dine leverandører. Kan bryte ToS → risiko for kontoutestengelse. Bruk på eget ansvar." + "⚠️ 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 avskjærer HTTPS-trafikk fra IDE-verktøy (Antigravity, GitHub Copilot, Kiro) via lokal CA for å omdirigere forespørsler til dine leverandører. Kan bryte ToS → risiko for kontoutestengelse. Bruk på eget ansvar.", + "Endpoint is exposed without an API key.": "Endepunktet er eksponert uten en API-nøkkel." } diff --git a/public/i18n/literals/pl.json b/public/i18n/literals/pl.json index 3b64484328e..f4b62a67fc9 100644 --- a/public/i18n/literals/pl.json +++ b/public/i18n/literals/pl.json @@ -190,5 +190,6 @@ "Sudo Password": "Hasło sudo", "Click to add, click again to remove. Changes are saved automatically.": "Kliknij, aby dodać, kliknij ponownie, aby usunąć. Zmiany są zapisywane automatycznie.", "⚠️ 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.": "⚠️ Ostrzeżenie o ryzyku: Ten dostawca używa sesji subskrypcji/OAuth, która nie jest oficjalnie licencjonowana do użytku proxy/routera. Konto może zostać ograniczone lub zbanowane. Używaj na własne ryzyko.", - "⚠️ 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 przechwytuje ruch HTTPS narzędzi IDE (Antigravity, GitHub Copilot, Kiro) przez lokalne CA, aby przekierować żądania do twoich dostawców. Może naruszyć ToS → ryzyko zbanowania konta. Używaj na własne ryzyko." + "⚠️ 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 przechwytuje ruch HTTPS narzędzi IDE (Antigravity, GitHub Copilot, Kiro) przez lokalne CA, aby przekierować żądania do twoich dostawców. Może naruszyć ToS → ryzyko zbanowania konta. Używaj na własne ryzyko.", + "Endpoint is exposed without an API key.": "Punkt końcowy jest dostępny bez klucza API." } diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index c603f1a2453..6edba2e7c32 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -190,5 +190,6 @@ "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.", - "⚠️ 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." + "⚠️ 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." } diff --git a/public/i18n/literals/pt-PT.json b/public/i18n/literals/pt-PT.json index b58f654d760..c17e932a294 100644 --- a/public/i18n/literals/pt-PT.json +++ b/public/i18n/literals/pt-PT.json @@ -190,5 +190,6 @@ "Sudo Password": "Palavra-passe sudo", "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são guardadas 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 fornecedor utiliza uma sessão de subscrição/OAuth não licenciada oficialmente para uso de proxy/router. A conta pode ser restringida ou banida. Use por sua conta e risco.", - "⚠️ 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 interceta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar pedidos para os seus fornecedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco." + "⚠️ 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 interceta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar pedidos para os seus fornecedores. 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." } diff --git a/public/i18n/literals/ro.json b/public/i18n/literals/ro.json index 7d21f06d86c..03384415b8e 100644 --- a/public/i18n/literals/ro.json +++ b/public/i18n/literals/ro.json @@ -190,5 +190,6 @@ "Sudo Password": "Parola Sudo", "Click to add, click again to remove. Changes are saved automatically.": "Faceți clic pentru a adăuga, faceți clic din nou pentru a elimina. Modificările sunt salvate automat.", "⚠️ 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.": "⚠️ Notificare de risc: Acest furnizor folosește un abonament/sesiune OAuth care nu este licențiat oficial pentru utilizare proxy/router. Contul poate fi restricționat sau interzis. Utilizați pe propriul risc.", - "⚠️ 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 interceptează traficul HTTPS al instrumentelor IDE (Antigravity, GitHub Copilot, Kiro) prin CA locală pentru a redirecționa cererile către furnizorii dvs. Poate încălca ToS → risc de interzicere a contului. Utilizați pe propriul risc." + "⚠️ 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 interceptează traficul HTTPS al instrumentelor IDE (Antigravity, GitHub Copilot, Kiro) prin CA locală pentru a redirecționa cererile către furnizorii dvs. Poate încălca ToS → risc de interzicere a contului. Utilizați pe propriul risc.", + "Endpoint is exposed without an API key.": "Endpointul este expus fără o cheie API." } diff --git a/public/i18n/literals/ru.json b/public/i18n/literals/ru.json index d235ec939f4..92b9f0e78de 100644 --- a/public/i18n/literals/ru.json +++ b/public/i18n/literals/ru.json @@ -190,5 +190,6 @@ "Sudo Password": "Пароль sudo", "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 → риск блокировки аккаунта. Используйте на свой страх и риск." + "⚠️ 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-ключа." } diff --git a/public/i18n/literals/sv.json b/public/i18n/literals/sv.json index 21ae9c3f226..0e4c3b46be7 100644 --- a/public/i18n/literals/sv.json +++ b/public/i18n/literals/sv.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo-lösenord", "Click to add, click again to remove. Changes are saved automatically.": "Klicka för att lägga till, klicka igen för att ta bort. Ändringar sparas automatiskt.", "⚠️ 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.": "⚠️ Riskmeddelande: Denna leverantör använder en prenumerations-/OAuth-session som inte är officiellt licensierad för proxy-/routeranvändning. Kontot kan begränsas eller bannlysas. Användning sker på egen risk.", - "⚠️ 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 avlyssnar HTTPS-trafik från IDE-verktyg (Antigravity, GitHub Copilot, Kiro) via lokal CA för att omdirigera förfrågningar till dina leverantörer. Kan bryta mot ToS → risk för kontoavstängning. Användning sker på egen risk." + "⚠️ 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 avlyssnar HTTPS-trafik från IDE-verktyg (Antigravity, GitHub Copilot, Kiro) via lokal CA för att omdirigera förfrågningar till dina leverantörer. Kan bryta mot ToS → risk för kontoavstängning. Användning sker på egen risk.", + "Endpoint is exposed without an API key.": "Slutpunkten är exponerad utan en API-nyckel." } diff --git a/public/i18n/literals/th.json b/public/i18n/literals/th.json index ad9e3a47e2d..6169829f723 100644 --- a/public/i18n/literals/th.json +++ b/public/i18n/literals/th.json @@ -190,5 +190,6 @@ "Sudo Password": "รหัสผ่าน Sudo", "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 → เสี่ยงถูกแบนบัญชี ใช้งานด้วยความเสี่ยงของคุณเอง" + "⚠️ 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" } diff --git a/public/i18n/literals/tl.json b/public/i18n/literals/tl.json index bb7939d8a4c..51af4e24124 100644 --- a/public/i18n/literals/tl.json +++ b/public/i18n/literals/tl.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo Password", "Click to add, click again to remove. Changes are saved automatically.": "I-click para idagdag, i-click muli para alisin. Ang mga pagbabago ay awtomatikong nase-save.", "⚠️ 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.": "⚠️ Babala sa Panganib: Ang provider na ito ay gumagamit ng subscription/OAuth session na hindi opisyal na lisensyado para sa proxy/router na paggamit. Maaaring marestrikta o ma-ban ang account. Gamitin sa sarili mong panganib.", - "⚠️ 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.": "⚠️ Hinaharang ng MITM ang HTTPS traffic ng mga IDE tool (Antigravity, GitHub Copilot, Kiro) sa pamamagitan ng lokal na CA upang i-redirect ang mga kahilingan sa iyong mga provider. Maaaring lumabag sa ToS → panganib ng pag-ban sa account. Gamitin sa sarili mong panganib." + "⚠️ 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.": "⚠️ Hinaharang ng MITM ang HTTPS traffic ng mga IDE tool (Antigravity, GitHub Copilot, Kiro) sa pamamagitan ng lokal na CA upang i-redirect ang mga kahilingan sa iyong mga provider. Maaaring lumabag sa ToS → panganib ng pag-ban sa account. Gamitin sa sarili mong panganib.", + "Endpoint is exposed without an API key.": "Nakalantad ang endpoint nang walang API key." } diff --git a/public/i18n/literals/tr.json b/public/i18n/literals/tr.json index 0a8556e3766..ac4042aaf3c 100644 --- a/public/i18n/literals/tr.json +++ b/public/i18n/literals/tr.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo Parolası", "Click to add, click again to remove. Changes are saved automatically.": "Eklemek için tıklayın, kaldırmak için tekrar tıklayın. Değişiklikler otomatik olarak kaydedilir.", "⚠️ 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.": "⚠️ Risk Bildirimi: Bu sağlayıcı, proxy/yönlendirici kullanımı için resmi olarak lisanslı olmayan bir abonelik/OAuth oturumu kullanır. Hesap kısıtlanabilir veya yasaklanabilir. Kendi sorumluluğunuzda kullanın.", - "⚠️ 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, isteklerinizi sağlayıcılarınıza yönlendirmek için yerel CA aracılığıyla IDE araçlarının (Antigravity, GitHub Copilot, Kiro) HTTPS trafiğini engeller. ToS'u ihlal edebilir → hesap yasaklama riski. Kendi sorumluluğunuzda kullanın." + "⚠️ 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, isteklerinizi sağlayıcılarınıza yönlendirmek için yerel CA aracılığıyla IDE araçlarının (Antigravity, GitHub Copilot, Kiro) HTTPS trafiğini engeller. ToS'u ihlal edebilir → hesap yasaklama riski. Kendi sorumluluğunuzda kullanın.", + "Endpoint is exposed without an API key.": "Uç nokta API anahtarı olmadan açıkta." } diff --git a/public/i18n/literals/uk.json b/public/i18n/literals/uk.json index 51e26440cf5..e238ad2b8fd 100644 --- a/public/i18n/literals/uk.json +++ b/public/i18n/literals/uk.json @@ -190,5 +190,6 @@ "Sudo Password": "Пароль Sudo", "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 → ризик блокування облікового запису. Використовуйте на свій страх і ризик." + "⚠️ 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-ключа." } diff --git a/public/i18n/literals/ur.json b/public/i18n/literals/ur.json index bb1c09bab86..e59217554b6 100644 --- a/public/i18n/literals/ur.json +++ b/public/i18n/literals/ur.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo پاس ورڈ", "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 آپ کے فراہم کنندگان کو درخواستوں کو ری ڈائریکٹ کرنے کے لیے مقامی CA کے ذریعے IDE ٹولز (Antigravity, GitHub Copilot, Kiro) کے HTTPS ٹریفک کو روکتا ہے۔ ToS کی خلاف ورزی کر سکتا ہے → اکاؤنٹ پابندی کا خطرہ۔ اپنے خطرے پر استعمال کریں۔" + "⚠️ 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 کلید کے بغیر بے نقاب ہے۔" } diff --git a/public/i18n/literals/vi.json b/public/i18n/literals/vi.json index 1b03b6e491e..5358d068176 100644 --- a/public/i18n/literals/vi.json +++ b/public/i18n/literals/vi.json @@ -190,5 +190,6 @@ "Sudo Password": "Mật khẩu Sudo", "Click to add, click again to remove. Changes are saved automatically.": "Nhấp để thêm, nhấp lại để xóa. Thay đổi được lưu tự động.", "⚠️ 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.": "⚠️ Cảnh báo rủi ro: Provider này sử dụng subscription/OAuth không được cấp phép chính thức cho mục đích proxy/router. Tài khoản có thể bị hạn chế hoặc cấm. Người dùng tự chịu trách nhiệm.", - "⚠️ 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 chặn lưu lượng HTTPS của các IDE tools (Antigravity, GitHub Copilot, Kiro) qua CA cục bộ để chuyển hướng request đến providers của bạn. Có thể vi phạm ToS → rủi ro bị ban tài khoản. Sử dụng với rủi ro của bạn." + "⚠️ 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 chặn lưu lượng HTTPS của các IDE tools (Antigravity, GitHub Copilot, Kiro) qua CA cục bộ để chuyển hướng request đến providers của bạn. Có thể vi phạm ToS → rủi ro bị ban tài khoản. Sử dụng với rủi ro của bạn.", + "Endpoint is exposed without an API key.": "Endpoint đang mở mà không có API key." } diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index a3282382028..7b3deb8170a 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -197,6 +197,8 @@ "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 CLI not installed": "Factory Droid CLI 未安装", + "Fetch Qoder Models": "获取 Qoder 模型", + "Fetching...": "获取中...", "Failed to load usage statistics.": "无法加载使用情况统计信息。", "Features": "功能特性", "Flush Interval (ms)": "刷新间隔(毫秒)", @@ -326,6 +328,7 @@ "Paste refresh token from Kiro IDE.": "从 Kiro IDE 粘贴刷新令牌。", "Paused": "已暂停", "Please add and connect providers first to configure CLI tools.": "请先添加并连接提供商以配置 CLI 工具。", + "Please add an active Qoder connection first": "请先添加一个活跃的 Qoder 连接", "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 才能使用此功能。", @@ -764,5 +767,6 @@ "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 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", - "⚠️ 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 → 账户封禁风险。使用风险自负。" + "⚠️ 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 密钥即对外暴露。" } diff --git a/public/i18n/literals/zh-TW.json b/public/i18n/literals/zh-TW.json index da44e1a20c3..ea9183ebdf4 100644 --- a/public/i18n/literals/zh-TW.json +++ b/public/i18n/literals/zh-TW.json @@ -190,5 +190,6 @@ "Sudo Password": "Sudo 密碼", "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 透過本地 CA 攔截 IDE 工具(Antigravity、GitHub Copilot、Kiro)的 HTTPS 流量,將請求重新導向到您的提供商。可能違反 ToS → 帳戶封禁風險。使用風險自負。" + "⚠️ 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 金鑰即對外暴露。" } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 2f55ca92bc1..1fe6c8e5f2e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -4,6 +4,10 @@ import { useState, useEffect, useRef, useCallback } from "react"; import PropTypes from "prop-types"; import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime"; + +// Locales that unlock wenyan (classical Chinese) caveman levels +const WENYAN_LOCALES = ["zh-CN", "zh-TW"]; const TUNNEL_BENEFITS = [ { icon: "public", title: "Access Anywhere", desc: "Use your API from any network" }, @@ -53,6 +57,9 @@ const CAVEMAN_LEVELS = [ { id: "lite", label: "Lite", desc: "Drop filler, keep grammar" }, { id: "full", label: "Full", desc: "Drop articles, fragments OK" }, { id: "ultra", label: "Ultra", desc: "Telegraphic, max compression" }, + { id: "wenyan-lite", label: "文 Lite", desc: "Classical Chinese, light compression", wenyan: true }, + { id: "wenyan", label: "文 Full", desc: "Maximum 文言文, 80-90% reduction", wenyan: true }, + { id: "wenyan-ultra", label: "文 Ultra", desc: "Extreme classical compression", wenyan: true }, ]; export default function APIPageClient({ machineId }) { const [keys, setKeys] = useState([]); @@ -74,6 +81,7 @@ export default function APIPageClient({ machineId }) { const [rtkEnabled, setRtkEnabledState] = useState(true); const [cavemanEnabled, setCavemanEnabled] = useState(false); const [cavemanLevel, setCavemanLevel] = useState("full"); + const [locale, setLocale] = useState("en"); // Email notification settings const [emailSettings, setEmailSettings] = useState({ @@ -142,6 +150,33 @@ export default function APIPageClient({ machineId }) { // API key visibility toggle state const [visibleKeys, setVisibleKeys] = useState(new Set()); + // Client-side local/remote detection (UI hint only, not a security gate) + const [isRemoteHost, setIsRemoteHost] = useState(false); + useEffect(() => { + if (typeof window !== "undefined") + setIsRemoteHost(!["localhost", "127.0.0.1", "::1"].includes(window.location.hostname)); + }, []); + + // Track app UI locale to gate wenyan caveman levels + useEffect(() => { + setLocale(getCurrentLocale()); + return onLocaleChange(() => setLocale(getCurrentLocale())); + }, []); + + const isWenyanLocale = WENYAN_LOCALES.includes(locale); + const visibleCavemanLevels = isWenyanLocale + ? CAVEMAN_LEVELS + : CAVEMAN_LEVELS.filter((lvl) => !lvl.wenyan); + + // Reset wenyan level to "ultra" when leaving a Chinese locale + useEffect(() => { + const current = CAVEMAN_LEVELS.find((lvl) => lvl.id === cavemanLevel); + if (current?.wenyan && !isWenyanLocale) { + setCavemanLevel("ultra"); + patchSetting({ cavemanLevel: "ultra" }); + } + }, [isWenyanLocale, cavemanLevel]); + const { copied, copy } = useCopyToClipboard(); // Security gate: block remote exposure while dashboard uses default password or login is off. @@ -1232,21 +1267,26 @@ export default function APIPageClient({ machineId }) {
{cavemanEnabled && ( -
- {CAVEMAN_LEVELS.map((lvl) => ( - - ))} +
+
+ {visibleCavemanLevels.map((lvl) => ( + + ))} +
+

+ {CAVEMAN_LEVELS.find((lvl) => lvl.id === cavemanLevel)?.desc} +

)}
+ {isRemoteHost && !requireApiKey && ( +
+ +
+ )} + {keys.length === 0 ? (
diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 5ec301e3f84..e5e40642409 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -1,13 +1,32 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; import { Card, Button, Toggle, Input } from "@/shared/components"; +import { ConfirmModal } from "@/shared/components/Modal"; +import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; import { useTheme } from "@/shared/hooks/useTheme"; import { cn } from "@/shared/utils/cn"; import { APP_CONFIG } from "@/shared/constants/config"; +import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; +import { LOCALE_FLAGS } from "@/shared/constants/locales"; + +function getLocaleFromCookie() { + if (typeof document === "undefined") return "en"; + const cookie = document.cookie + .split(";") + .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); + const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; + return normalizeLocale(value); +} export default function ProfilePage() { + const router = useRouter(); const { theme, setTheme, isDark } = useTheme(); + const [locale, setLocale] = useState("en"); + const [langOpen, setLangOpen] = useState(false); + const [shutdownOpen, setShutdownOpen] = useState(false); + const [isShuttingDown, setIsShuttingDown] = useState(false); const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); const [loading, setLoading] = useState(true); const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); @@ -39,6 +58,10 @@ export default function ProfilePage() { const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); + useEffect(() => { + setLocale(getLocaleFromCookie()); + }, [langOpen]); + useEffect(() => { fetch("/api/settings") .then((res) => res.json()) @@ -515,6 +538,29 @@ export default function ProfilePage() { const observabilityEnabled = settings.enableObservability === true; + const handleShutdown = async () => { + setIsShuttingDown(true); + try { + await fetch("/api/version/shutdown", { method: "POST" }); + } catch (e) { + // Expected to fail as server shuts down; ignore error + } + setIsShuttingDown(false); + setShutdownOpen(false); + }; + + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { method: "POST" }); + if (res.ok) { + router.push("/login"); + router.refresh(); + } + } catch (err) { + console.error("Failed to logout:", err); + } + }; + return (
@@ -593,6 +639,24 @@ export default function ProfilePage() {
+ {/* Language */} + +
+
+ language +
+

Language

+
+ +
+ {/* Security */}
@@ -1024,12 +1088,53 @@ export default function ProfilePage() {
+ {/* Account actions */} +
+ + +
+ {/* App Info */}

{APP_CONFIG.name} v{APP_CONFIG.version}

Local Mode - All data stored on your machine

+ + { + setLangOpen(false); + setLocale(next); + }} + /> + setShutdownOpen(false)} + onConfirm={handleShutdown} + title="Close Proxy" + message="Are you sure you want to close the proxy server?" + confirmText="Close" + cancelText="Cancel" + variant="danger" + loading={isShuttingDown} + />
); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 842096df48d..e49827b1118 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -8,6 +8,7 @@ import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthW import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { translate } from "@/i18n/runtime"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import ModelRow from "./ModelRow"; import PassthroughModelsSection from "./PassthroughModelsSection"; @@ -62,6 +63,7 @@ export default function ProviderDetailPage() { const [oneByOneResults, setOneByOneResults] = useState({}); const [oneByOneSummary, setOneByOneSummary] = useState(null); const stopOneByOneRef = useRef(false); + const [importingQoderModels, setImportingQoderModels] = useState(false); const { copied, copy } = useCopyToClipboard(); const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; @@ -409,6 +411,66 @@ export default function ProviderDetailPage() { } }; + // Fetch Qoder model list and automatically add to available models + const handleImportQoderModels = async () => { + if (importingQoderModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) { + alert(translate("Please add an active Qoder connection first")); + return; + } + + setImportingQoderModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || translate("Failed to fetch models")); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert(translate("No models returned")); + return; + } + + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name; + if (!modelId) continue; + + // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix + const cleanModelId = modelId.replace(/^qoder\//, ""); + const fullModel = `${providerStorageAlias}/${cleanModelId}`; + + // Check if already exists + if (Object.values(modelAliases).includes(fullModel)) { + continue; + } + + // Use model ID as alias + const alias = cleanModelId; + if (modelAliases[alias]) { + continue; + } + + await handleSetAlias(cleanModelId, alias, providerStorageAlias); + importedCount += 1; + } + + if (importedCount === 0) { + alert(translate("All models already exist, no new models added")); + } else { + alert(translate("Successfully added") + ` ${importedCount} ` + translate("models")); + } + } catch (error) { + console.log("Error importing Qoder models:", error); + alert(translate("Error fetching models") + ": " + error.message); + } finally { + setImportingQoderModels(false); + } + }; + const handleRunOneByOneTest = async () => { if (oneByOneRunning || connections.length === 0) return; @@ -926,6 +988,20 @@ export default function ProviderDetailPage() { Add Model + {/* Import Qoder models button — only show for qoder provider */} + {providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && ( + + )} + {/* Suggested models from provider API — show only models not yet added */} {suggestedModels.length > 0 && (() => { const addedFullModels = new Set(Object.values(modelAliases)); diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js new file mode 100644 index 00000000000..5b7ee8a00e4 --- /dev/null +++ b/src/app/api/models/test/ping.js @@ -0,0 +1,191 @@ +import { getApiKeys } from "@/lib/localDb"; +import { UPDATER_CONFIG } from "@/shared/constants/config"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +const CLI_TOKEN_SALT = "9r-cli-auth"; + +function createSilentWavFile() { + const sampleRate = 16000; + const channels = 1; + const bitsPerSample = 16; + const durationMs = 250; + const sampleCount = Math.max(1, Math.floor((sampleRate * durationMs) / 1000)); + const dataSize = sampleCount * channels * (bitsPerSample / 8); + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + const writeAscii = (offset, value) => { + for (let i = 0; i < value.length; i += 1) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + }; + + writeAscii(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeAscii(8, "WAVE"); + writeAscii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true); + view.setUint16(32, channels * (bitsPerSample / 8), true); + view.setUint16(34, bitsPerSample, true); + writeAscii(36, "data"); + view.setUint32(40, dataSize, true); + + return new Blob([buffer], { type: "audio/wav" }); +} + +async function getInternalHeaders() { + let apiKey = null; + try { + const keys = await getApiKeys(); + apiKey = keys.find((k) => k.isActive !== false)?.key || null; + } catch {} + + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT); + return headers; +} + +export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`) { + const headers = await getInternalHeaders(); + const start = Date.now(); + + if (kind === "embedding") { + const res = await fetch(`${baseUrl}/api/v1/embeddings`, { + method: "POST", + headers, + body: JSON.stringify({ model, input: "test" }), + signal: AbortSignal.timeout(15000), + }); + const latencyMs = Date.now() - start; + const rawText = await res.text().catch(() => ""); + let parsed = null; + try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + + if (!res.ok) { + const detail = parsed?.error?.message || parsed?.error || rawText; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + } + const hasEmbedding = Array.isArray(parsed?.data) && parsed.data.length > 0 && Array.isArray(parsed.data[0]?.embedding); + if (!hasEmbedding) { + return { ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" }; + } + return { ok: true, latencyMs, error: null, status: res.status }; + } + + if (kind === "image") { + const res = await fetch(`${baseUrl}/api/v1/images/generations`, { + method: "POST", + headers, + body: JSON.stringify({ model, prompt: "test" }), + signal: AbortSignal.timeout(15000), + }); + const latencyMs = Date.now() - start; + const rawText = await res.text().catch(() => ""); + let parsed = null; + try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + + if (!res.ok) { + const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + } + + const hasImages = Array.isArray(parsed?.data) && parsed.data.length > 0; + if (!hasImages) { + return { ok: false, latencyMs, status: res.status, error: "Provider returned no image data for this model" }; + } + return { ok: true, latencyMs, error: null, status: res.status }; + } + + if (kind === "stt") { + const form = new FormData(); + const sampleAudio = createSilentWavFile(); + form.append("file", sampleAudio, "test.wav"); + form.append("model", model); + + const res = await fetch(`${baseUrl}/api/v1/audio/transcriptions`, { + method: "POST", + headers: Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "content-type")), + body: form, + signal: AbortSignal.timeout(15000), + }); + const latencyMs = Date.now() - start; + const rawText = await res.text().catch(() => ""); + let parsed = null; + try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + + if (!res.ok) { + const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + } + + const text = typeof parsed?.text === "string" ? parsed.text : ""; + if (!text.trim()) { + return { ok: false, latencyMs, status: res.status, error: "Provider returned no transcription text for this model" }; + } + return { ok: true, latencyMs, error: null, status: res.status }; + } + + const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: 1, + stream: false, + messages: [{ role: "user", content: "hi" }], + }), + signal: AbortSignal.timeout(15000), + }); + const latencyMs = Date.now() - start; + + const rawText = await res.text().catch(() => ""); + let parsed = null; + try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + + if (!res.ok) { + const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + } + + const providerStatus = parsed?.status; + const providerMsg = parsed?.msg || parsed?.message; + const hasProviderErrorStatus = providerStatus !== undefined + && providerStatus !== null + && String(providerStatus) !== "200" + && String(providerStatus) !== "0"; + if (hasProviderErrorStatus && providerMsg) { + return { + ok: false, + latencyMs, + status: res.status, + error: `Provider status ${providerStatus}: ${String(providerMsg).slice(0, 240)}`, + }; + } + + if (parsed?.error) { + const providerError = parsed?.error?.message || parsed?.error || "Provider returned an error"; + return { + ok: false, + latencyMs, + status: res.status, + error: String(providerError).slice(0, 240), + }; + } + + const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0; + if (!hasChoices) { + return { + ok: false, + latencyMs, + status: res.status, + error: "Provider returned no completion choices for this model", + }; + } + + return { ok: true, latencyMs, error: null, status: res.status }; +} diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js index bad3fe74ee6..0b35c9f305d 100644 --- a/src/app/api/models/test/route.js +++ b/src/app/api/models/test/route.js @@ -1,119 +1,13 @@ import { NextResponse } from "next/server"; -import { getApiKeys } from "@/lib/localDb"; -import { UPDATER_CONFIG } from "@/shared/constants/config"; -import { getConsistentMachineId } from "@/shared/utils/machineId"; - -const CLI_TOKEN_SALT = "9r-cli-auth"; +import { pingModelByKind } from "./ping"; // POST /api/models/test - Ping a single model via internal completions or embeddings export async function POST(request) { try { const { model, kind } = await request.json(); if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 }); - - const baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`; - - // Get an active internal API key for auth (if requireApiKey is enabled) - let apiKey = null; - try { - const keys = await getApiKeys(); - apiKey = keys.find((k) => k.isActive !== false)?.key || null; - } catch {} - - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - // Bypass dashboardGuard for internal self-call via CLI token (machineId-based) - headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT); - - const start = Date.now(); - - // Route to appropriate endpoint based on kind - if (kind === "embedding") { - const res = await fetch(`${baseUrl}/api/v1/embeddings`, { - method: "POST", - headers, - body: JSON.stringify({ model, input: "test" }), - signal: AbortSignal.timeout(15000), - }); - const latencyMs = Date.now() - start; - const rawText = await res.text().catch(() => ""); - let parsed = null; - try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} - - if (!res.ok) { - const detail = parsed?.error?.message || parsed?.error || rawText; - return NextResponse.json({ ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }); - } - const hasEmbedding = Array.isArray(parsed?.data) && parsed.data.length > 0 && Array.isArray(parsed.data[0]?.embedding); - if (!hasEmbedding) { - return NextResponse.json({ ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" }); - } - return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status }); - } - - // Default: chat completions - const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ - model, - max_tokens: 1, - stream: false, - messages: [{ role: "user", content: "hi" }], - }), - signal: AbortSignal.timeout(15000), - }); - const latencyMs = Date.now() - start; - - const rawText = await res.text().catch(() => ""); - let parsed = null; - try { - parsed = rawText ? JSON.parse(rawText) : null; - } catch {} - - if (!res.ok) { - const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; - const error = `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`; - return NextResponse.json({ ok: false, latencyMs, error, status: res.status }); - } - - // Some providers may return HTTP 200 but not a real completion for invalid models. - const providerStatus = parsed?.status; - const providerMsg = parsed?.msg || parsed?.message; - const hasProviderErrorStatus = providerStatus !== undefined - && providerStatus !== null - && String(providerStatus) !== "200" - && String(providerStatus) !== "0"; - if (hasProviderErrorStatus && providerMsg) { - return NextResponse.json({ - ok: false, - latencyMs, - status: res.status, - error: `Provider status ${providerStatus}: ${String(providerMsg).slice(0, 240)}`, - }); - } - - if (parsed?.error) { - const providerError = parsed?.error?.message || parsed?.error || "Provider returned an error"; - return NextResponse.json({ - ok: false, - latencyMs, - status: res.status, - error: String(providerError).slice(0, 240), - }); - } - - const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0; - if (!hasChoices) { - return NextResponse.json({ - ok: false, - latencyMs, - status: res.status, - error: "Provider returned no completion choices for this model", - }); - } - - return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status }); + const result = await pingModelByKind(model, kind || "llm"); + return NextResponse.json(result); } catch (err) { return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index f126727f983..23aacabb0c8 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -1,59 +1,14 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById, getApiKeys } from "@/lib/localDb"; +import { getProviderConnectionById } from "@/lib/localDb"; import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { UPDATER_CONFIG } from "@/shared/constants/config"; -import { getConsistentMachineId } from "@/shared/utils/machineId"; - -const CLI_TOKEN_SALT = "9r-cli-auth"; - -/** - * Get an active API key to pass through auth when requireApiKey is enabled. - */ -async function getInternalApiKey() { - const keys = await getApiKeys(); - return keys.find((k) => k.isActive !== false)?.key || null; -} - -/** - * Ping a single model via internal completions endpoint (OpenAI format). - * open-sse handles all provider translation automatically. - */ -async function pingModel(modelId, baseUrl, apiKey, cliToken) { - const start = Date.now(); - try { - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - if (cliToken) headers["x-9r-cli-token"] = cliToken; - const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ - model: modelId, - max_tokens: 1, - stream: false, - messages: [{ role: "user", content: "hi" }], - }), - signal: AbortSignal.timeout(15000), - }); - const latencyMs = Date.now() - start; - // 200 = working; 400 = bad request but auth passed (model reachable) - const ok = res.status === 200 || res.status === 400; - let error = null; - if (!ok) { - const text = await res.text().catch(() => ""); - error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`; - } - return { ok, latencyMs, error }; - } catch (err) { - return { ok: false, latencyMs: Date.now() - start, error: err.message }; - } -} +import { pingModelByKind } from "@/app/api/models/test/ping"; /** * POST /api/providers/[id]/test-models * id = connectionId — used only to resolve provider + model list. - * Actual requests go through /api/v1/chat/completions (open-sse handles everything). + * Actual requests go through the internal endpoint that matches each model kind. */ export async function POST(request, { params }) { try { @@ -86,20 +41,17 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 }); } - const apiKey = await getInternalApiKey(); - // Bypass dashboardGuard for internal self-call via CLI token (machineId-based) - const cliToken = await getConsistentMachineId(CLI_TOKEN_SALT); - // Warm up with first model to trigger token refresh (if needed) before parallel calls. // This prevents race condition where multiple requests concurrently refresh the same token. const [first, ...rest] = models; - const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey, cliToken); + const firstKind = first.type || "llm"; + const firstResult = await pingModelByKind(`${alias}/${first.id}`, firstKind, baseUrl); const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }]; if (rest.length > 0) { const restResults = await Promise.all( rest.map(async (model) => { - const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey, cliToken); + const result = await pingModelByKind(`${alias}/${model.id}`, model.type || "llm", baseUrl); return { modelId: model.id, name: model.name || model.id, ...result }; }) ); diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 6d657ecd269..d0725d83830 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -5,10 +5,13 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha import { PROVIDER_ENDPOINTS } from "@/shared/constants/config"; import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "open-sse/services/oauthCredentialManager.js"; import { GEMINI_CONFIG, ANTIGRAVITY_CONFIG, - CODEX_CONFIG, KIRO_CONFIG, QWEN_CONFIG, CLAUDE_CONFIG, @@ -25,7 +28,7 @@ const OAUTH_TEST_CONFIG = { method: "POST", authHeader: "Authorization", authPrefix: "Bearer ", - extraHeaders: { "Content-Type": "application/json", "originator": "codex-cli", "User-Agent": "codex-cli/1.0.18 (macOS; arm64)" }, + extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": "codex_cli_rs/0.136.0" }, // Minimal invalid body — triggers fast 400 without consuming quota body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }), // 400 (bad request) means auth succeeded; only 401/403 means token is bad @@ -126,18 +129,7 @@ async function refreshOAuthToken(connection) { } if (provider === "codex") { - const response = await fetch(CODEX_CONFIG.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: CODEX_CONFIG.clientId, - refresh_token: refreshToken, - }), - }); - if (!response.ok) return null; - const data = await response.json(); - return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + return await refreshProviderCredentials(provider, connection, console); } if (provider === "claude") { @@ -227,10 +219,7 @@ async function refreshOAuthToken(connection) { } function isTokenExpired(connection) { - if (!connection.expiresAt) return false; - const expiresAt = new Date(connection.expiresAt).getTime(); - const buffer = 5 * 60 * 1000; - return expiresAt <= Date.now() + buffer; + return shouldRefreshCredentials(connection.provider, connection); } async function testOAuthConnection(connection, effectiveProxy = null) { @@ -673,14 +662,25 @@ export async function testSingleConnection(id) { }; if (result.refreshed && result.newTokens) { - updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.accessToken) updateData.accessToken = result.newTokens.accessToken; if (result.newTokens.refreshToken) updateData.refreshToken = result.newTokens.refreshToken; + if (result.newTokens.idToken) updateData.idToken = result.newTokens.idToken; + if (result.newTokens.lastRefreshAt) updateData.lastRefreshAt = result.newTokens.lastRefreshAt; + if (result.newTokens.expiresIn) updateData.expiresIn = result.newTokens.expiresIn; if (result.newTokens.expiresIn) { updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); + } else if (result.newTokens.expiresAt) { + updateData.expiresAt = result.newTokens.expiresAt; + } + if (result.newTokens.providerSpecificData) { + updateData.providerSpecificData = { + ...(connection.providerSpecificData || {}), + ...result.newTokens.providerSpecificData, + }; } } await updateProviderConnection(id, updateData); - return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() }; + return { valid: result.valid, error: result.error, refreshed: !!result.refreshed, latencyMs, testedAt: new Date().toISOString() }; } diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index e4c7dc8ffe7..f289cd25100 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -122,11 +122,18 @@ export async function POST(request) { let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData); + // Compatible/embedding nodes allow exactly one connection each. These guards were + // dropped accidentally during the bun:sqlite refactor (v0.4.28); restored to honor + // the contract locked in by tests/unit/compatible-provider-connections.test.js (#925). if (isOpenAICompatibleProvider(provider)) { const node = await getProviderNodeById(provider); if (!node) { return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); } + const existingConnections = await getProviderConnections({ provider }); + if (existingConnections.length > 0) { + return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 }); + } providerSpecificData = { prefix: node.prefix, apiType: node.apiType, @@ -138,6 +145,10 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); } + const existingConnections = await getProviderConnections({ provider }); + if (existingConnections.length > 0) { + return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 }); + } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, @@ -148,6 +159,10 @@ export async function POST(request) { if (!node) { return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 }); } + const existingConnections = await getProviderConnections({ provider }); + if (existingConnections.length > 0) { + return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 }); + } providerSpecificData = { prefix: node.prefix, baseUrl: node.baseUrl, diff --git a/src/app/api/translator/send/route.js b/src/app/api/translator/send/route.js index d725610a108..0e752ef024f 100644 --- a/src/app/api/translator/send/route.js +++ b/src/app/api/translator/send/route.js @@ -1,5 +1,36 @@ -import { getProviderConnections } from "@/lib/localDb.js"; -import { getExecutor, refreshTokenByProvider } from "open-sse/index.js"; +import { getProviderConnections, updateProviderConnection } from "@/lib/localDb.js"; +import { getExecutor } from "open-sse/index.js"; + +async function persistRefreshedCredentials(connection, newCredentials) { + const updateData = {}; + + if (newCredentials.accessToken) updateData.accessToken = newCredentials.accessToken; + if (newCredentials.refreshToken) updateData.refreshToken = newCredentials.refreshToken; + if (newCredentials.idToken) updateData.idToken = newCredentials.idToken; + if (newCredentials.lastRefreshAt) updateData.lastRefreshAt = newCredentials.lastRefreshAt; + if (newCredentials.expiresIn) { + updateData.expiresIn = newCredentials.expiresIn; + updateData.expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString(); + } else if (newCredentials.expiresAt) { + updateData.expiresAt = newCredentials.expiresAt; + } + + const providerSpecificUpdates = { + ...(newCredentials.providerSpecificData || {}), + ...(newCredentials.copilotToken ? { copilotToken: newCredentials.copilotToken } : {}), + ...(newCredentials.copilotTokenExpiresAt ? { copilotTokenExpiresAt: newCredentials.copilotTokenExpiresAt } : {}), + }; + if (Object.keys(providerSpecificUpdates).length > 0) { + updateData.providerSpecificData = { + ...(connection.providerSpecificData || {}), + ...providerSpecificUpdates, + }; + } + + if (Object.keys(updateData).length > 0) { + await updateProviderConnection(connection.id, updateData); + } +} export async function POST(request) { try { @@ -19,7 +50,11 @@ export async function POST(request) { apiKey: connection.apiKey, accessToken: connection.accessToken, refreshToken: connection.refreshToken, - copilotToken: connection.copilotToken, + idToken: connection.idToken, + lastRefreshAt: connection.lastRefreshAt, + connectionId: connection.id, + copilotToken: connection.providerSpecificData?.copilotToken, + copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, projectId: connection.projectId, providerSpecificData: connection.providerSpecificData }; @@ -31,9 +66,10 @@ export async function POST(request) { // Auto-refresh token on 401/403 and retry (same as chatCore.js) if (response.status === 401 || response.status === 403) { - const newCredentials = await refreshTokenByProvider(provider, credentials); + const newCredentials = await executor.refreshCredentials(credentials, console); if (newCredentials?.accessToken || newCredentials?.copilotToken) { Object.assign(credentials, newCredentials); + await persistRefreshedCredentials(connection, newCredentials); ({ response } = await executor.execute({ model, body, stream, credentials })); } } diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 3866e8625b0..3d23274bd16 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -225,9 +225,12 @@ const PROVIDERS = { const mapped = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, + idToken: tokens.id_token, expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), }; - if (info.email) mapped.email = info.email; + const email = info.email || extractEmailFromAccessToken(tokens.access_token); + if (email) mapped.email = email; if (info.chatgptAccountId || info.chatgptPlanType) { mapped.providerSpecificData = { chatgptAccountId: info.chatgptAccountId, diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js index fdc0310e426..72417aa6a52 100644 --- a/src/lib/oauth/services/codex.js +++ b/src/lib/oauth/services/codex.js @@ -54,6 +54,7 @@ export class CodexService extends OAuthService { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), }), }); @@ -141,4 +142,3 @@ export class CodexService extends OAuthService { } } } - diff --git a/src/lib/qoder/constants.js b/src/lib/qoder/constants.js index 54b51549cb7..1d9ce303a28 100644 --- a/src/lib/qoder/constants.js +++ b/src/lib/qoder/constants.js @@ -46,6 +46,7 @@ export const QODER_MODEL_MAP = { lite: "lite", // Frontier models qmodel: "qmodel", + qmodel_latest: "qmodel_latest", dmodel: "dmodel", dfmodel: "dfmodel", gm51model: "gm51model", diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js index a539d49cb27..e6f0c434bda 100644 --- a/src/lib/tunnel/index.js +++ b/src/lib/tunnel/index.js @@ -44,4 +44,5 @@ export { NETWORK_SETTLE_MS, WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, + VIRTUAL_IFACE_REGEX, } from "./shared/watchdogConfig.js"; diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js index bcef4934abf..0274997bcd6 100644 --- a/src/lib/tunnel/shared/watchdogConfig.js +++ b/src/lib/tunnel/shared/watchdogConfig.js @@ -3,3 +3,6 @@ export const RESTART_COOLDOWN_MS = 120000; export const NETWORK_SETTLE_MS = 2500; export const WATCHDOG_INTERVAL_MS = 60000; export const NETWORK_CHECK_INTERVAL_MS = 5000; + +// Skip virtual/transient interfaces (tailscale utun, AirDrop awdl, bridges) that flap and cause false netchange +export const VIRTUAL_IFACE_REGEX = /^(utun|awdl|llw|anpi|bridge|gif|stf|ipsec|ap|tun|tap|vmnet|veth|docker)/i; diff --git a/src/lib/usage/providerQuota.js b/src/lib/usage/providerQuota.js index 70cc723afc9..a1f2f95e975 100644 --- a/src/lib/usage/providerQuota.js +++ b/src/lib/usage/providerQuota.js @@ -27,7 +27,10 @@ export async function refreshAndUpdateCredentials(connection, force = false, pro const credentials = { accessToken: connection.accessToken, refreshToken: connection.refreshToken, + idToken: connection.idToken, expiresAt: connection.expiresAt || connection.tokenExpiresAt, + lastRefreshAt: connection.lastRefreshAt, + connectionId: connection.id, providerSpecificData: connection.providerSpecificData, copilotToken: connection.providerSpecificData?.copilotToken, copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, @@ -59,16 +62,27 @@ export async function refreshAndUpdateCredentials(connection, force = false, pro if (refreshResult.refreshToken) { updateData.refreshToken = refreshResult.refreshToken; } + if (refreshResult.idToken) { + updateData.idToken = refreshResult.idToken; + } + if (refreshResult.lastRefreshAt) { + updateData.lastRefreshAt = refreshResult.lastRefreshAt; + } if (refreshResult.expiresIn) { updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + updateData.expiresIn = refreshResult.expiresIn; } else if (refreshResult.expiresAt) { updateData.expiresAt = refreshResult.expiresAt; } - if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) { + const providerSpecificUpdates = { + ...(refreshResult.providerSpecificData || {}), + ...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}), + ...(refreshResult.copilotTokenExpiresAt ? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt } : {}), + }; + if (Object.keys(providerSpecificUpdates).length > 0) { updateData.providerSpecificData = { - ...connection.providerSpecificData, - copilotToken: refreshResult.copilotToken, - copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt, + ...(connection.providerSpecificData || {}), + ...providerSpecificUpdates, }; } @@ -78,6 +92,7 @@ export async function refreshAndUpdateCredentials(connection, force = false, pro connection: { ...connection, ...updateData, + providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData, }, refreshed: true, }; diff --git a/src/mitm/config.js b/src/mitm/config.js index eb8aefc9e8c..bddcb74b39a 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -32,6 +32,9 @@ const URL_PATTERNS = { const MODEL_SYNONYMS = { antigravity: { "gemini-default": "gemini-3.5-flash-low", + "gemini-3.5-flash-high": "gemini-3-flash-agent", + "gemini-3.5-flash-medium": "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low", "gemini-3.1-pro-high": "gemini-pro-agent", "gemini-3-pro-high": "gemini-pro-agent", "gemini-3-pro-low": "gemini-3.1-pro-low", @@ -42,7 +45,8 @@ const MODEL_SYNONYMS = { // Order matters: more specific patterns first. Catches AG renamed variants (e.g. gemini-pro-agent) const MODEL_PATTERNS = { antigravity: [ - { match: /flash.*low|low.*flash|flash.*medium|medium.*flash/i, alias: "gemini-3.5-flash-low" }, + { match: /flash.*extra.*low|extra.*low.*flash|flash.*low|low.*flash/i, alias: "gemini-3.5-flash-extra-low" }, + { match: /flash.*medium|medium.*flash/i, alias: "gemini-3.5-flash-low" }, { match: /flash.*agent|agent.*flash|flash/i, alias: "gemini-3-flash-agent" }, { match: /pro.*low|low.*pro/i, alias: "gemini-3.1-pro-low" }, { match: /gemini.*pro|pro.*gemini/i, alias: "gemini-pro-agent" }, @@ -52,6 +56,16 @@ const MODEL_PATTERNS = { ], }; +// Models that must NEVER be re-routed — always passthrough to the real upstream, even when +// the tool's other models are mapped. Antigravity's tab-autocomplete (`tab_jump_flash_lite_preview`, +// `tab_flash_lite_preview`, requestType tab/tab_jump) is latency-critical inline completion; routing +// it through 9Router to an external chat model makes typing laggy and burns provider quota per +// keystroke. Without this guard the broad `flash` pattern in MODEL_PATTERNS hijacks them onto the +// flash-agent slot. Verified via MITM dump capture of streamGenerateContent (see AI_JOURNAL). +const MODEL_NO_MAP = { + antigravity: [/^tab[_-]/i], +}; + // URL substrings whose request/response should NOT be dumped to file (telemetry, polling, empty) const LOG_BLACKLIST_URL_PARTS = [ "recordCodeAssistMetrics", @@ -70,4 +84,4 @@ function getToolForHost(host) { return null; } -module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, LOG_BLACKLIST_URL_PARTS, getToolForHost }; +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js index 74ac35bb4ec..5f2d3d12676 100644 --- a/src/mitm/handlers/base.js +++ b/src/mitm/handlers/base.js @@ -65,4 +65,162 @@ async function pipeSSE(routerRes, res, dumper) { } } -module.exports = { fetchRouter, pipeSSE }; +/** + * Pipe SSE stream from router, transforming each chunk through a user function. + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned SSE strings to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => string|string[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedSSE(routerRes, res, transformFn, state) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +/** + * Pipe SSE stream from router, transforming each chunk through a user function, + * and writing binary EventStream frames to the client. + * + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned Uint8Array frames to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => Uint8Array|Uint8Array[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedEventStream(routerRes, res, transformFn, state) { + const resHeaders = { + "Content-Type": "application/vnd.amazon.eventstream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +module.exports = { fetchRouter, pipeSSE, pipeTransformedSSE, pipeTransformedEventStream }; \ No newline at end of file diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js index fa7b01629ad..b453d2cba0d 100644 --- a/src/mitm/handlers/kiro.js +++ b/src/mitm/handlers/kiro.js @@ -1,6 +1,6 @@ const { err } = require("../logger"); const { IS_DEV } = require("../config"); -const { fetchRouter } = require("./base"); +const { fetchRouter, pipeTransformedEventStream } = require("./base"); const fs = require("fs"); const path = require("path"); @@ -32,6 +32,76 @@ function crc32(buf) { return (crc ^ 0xffffffff) >>> 0; } +/** + * Initialize state for the Kiro response translator + */ +function initKiroState(modelId) { + return { + modelId: modelId || null, // Model name from first chunk + toolCallInit: {}, // { [index]: { id, name } } — tracks seen tools + hasToolCalls: false, // Whether this response uses tool calls + finishSent: false, // Whether termination has been emitted + usage: null, // Accumulated usage from usage-only chunks + inThink: false, // Whether inside a block + thinkBuf: "" // Buffer for partial thinking content + }; +} + +/** + * Extract thinking blocks from text content. + * Handles both ... and ... tags, + * including partial tags split across SSE chunks. + */ +function extractThinking(text, state) { + if (!text) return { thinking: null, text: null }; + + let working = text; + + // Prepend buffered partial thinking from previous chunk + if (state.inThink && state.thinkBuf) { + working = state.thinkBuf + working; + state.thinkBuf = ""; + state.inThink = false; + } + + // Match or opening tags + const startRe = /|/i; + const startMatch = working.match(startRe); + + if (!startMatch) { + return { thinking: null, text: working }; + } + + const tag = startMatch[0].toLowerCase(); + const closeTag = tag === "" ? "" : ""; + const startIdx = startMatch.index; + const endIdx = working.indexOf(closeTag, startIdx + tag.length); + + if (endIdx === -1) { + // Opening tag without closing — buffer for next chunk + state.inThink = true; + state.thinkBuf = working.slice(startIdx); + const before = working.slice(0, startIdx).trim(); + return { thinking: null, text: before || null }; + } + + // Complete block found + const thinking = working.slice(startIdx + tag.length, endIdx); + const before = working.slice(0, startIdx).trim(); + const after = working.slice(endIdx + closeTag.length).trim(); + const rest = [before, after].filter(Boolean).join(""); + + // Recursively process for more blocks + const recurse = rest + ? extractThinking(rest, { inThink: false, thinkBuf: "" }) + : { thinking: null, text: null }; + + return { + thinking: thinking || null, + text: recurse.text || null + }; +} + // ─── AWS EventStream frame builder ──────────────────────────────────────────── /** * Encode a single string header into the AWS EventStream binary format. @@ -233,132 +303,173 @@ function extractTools(body) { } // ─── OpenAI SSE → EventStream binary conversion ─────────────────────────────── + /** - * Read 9router's OpenAI SSE response and re-encode it as AWS EventStream binary - * frames that Kiro's Smithy SDK expects. + * Convert an OpenAI SSE chunk to AWS EventStream binary frame(s) + * This replaces pipeOpenAIasEventStream and works with pipeTransformedEventStream * - * OpenAI SSE format: data: { choices:[{ delta:{ content:"..." } }] }\n\n - * EventStream events emitted: - * assistantResponseEvent { content: "..." } — one per SSE chunk with text - * toolUseEvent { toolUseId, name, input } — for tool calls - * messageStopEvent {} — on finish + * @param {object|null} chunk - Parsed OpenAI chat.completion.chunk, or null for flush + * @param {object} state - Mutable state object + * @returns {Uint8Array|Uint8Array[]|null} Binary EventStream frame(s) or null to skip */ -async function pipeOpenAIasEventStream(routerRes, res) { - if (!routerRes.body) { - res.end(buildEventStreamFrame("messageStopEvent", {})); - return; +function convertOpenAIToKiro(chunk, state) { + // Flush: ensure clean stream termination + if (!chunk) { + if (state.finishSent) return null; + // Flush any remaining buffered thinking + if (state.inThink && state.thinkBuf) { + state.inThink = false; + const thinking = state.thinkBuf; + state.thinkBuf = ""; + return buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId: state.modelId || "kiro-unknown" + }); + } + return buildEventStreamFrame("messageStopEvent", {}); } - const reader = routerRes.body.getReader(); - const decoder = new TextDecoder(); - let sseBuffer = ""; - let stopSent = false; + const frames = []; + const choice = chunk.choices?.[0]; + const delta = choice?.delta || {}; - // Accumulated tool-call state keyed by index - const toolCallAccum = {}; + // Capture modelId from first chunk (real API includes it in every content frame) + if (!state.modelId && chunk.model) { + state.modelId = chunk.model; + } + const modelId = state.modelId || "unknown"; - const sendStop = () => { - if (!stopSent) { - stopSent = true; - res.write(buildEventStreamFrame("messageStopEvent", {})); - } - }; + // Handle usage (may arrive standalone or with other chunks) + if (chunk.usage) { + state.usage = chunk.usage; + } - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - sseBuffer += decoder.decode(value, { stream: true }); - - // Split on newlines; keep the last (possibly incomplete) line in the buffer - const lines = sseBuffer.split("\n"); - sseBuffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const raw = trimmed.slice(5).trim(); - if (raw === "[DONE]") { - sendStop(); - continue; - } - - let chunk; - try { chunk = JSON.parse(raw); } catch { continue; } - - const delta = chunk?.choices?.[0]?.delta; - if (!delta) continue; - - // ── Text content ─────────────────────────────────────────────────────── - if (delta.content) { - res.write(buildEventStreamFrame("assistantResponseEvent", { content: delta.content })); - } - - // ── Tool calls (streamed in pieces by OpenAI SSE) ────────────────────── - if (delta.tool_calls) { - dbg(`TOOL_CALLS delta: ${JSON.stringify(delta.tool_calls).slice(0, 300)}`); - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - if (!toolCallAccum[idx]) { - toolCallAccum[idx] = { id: tc.id ?? "", name: "", args: "" }; - } - const acc = toolCallAccum[idx]; - if (tc.id) acc.id = tc.id; - if (tc.function?.name) acc.name += tc.function.name; - // safeArgsString prevents `"" + object` → "[object Object]" corruption - // when 9router's Anthropic→OpenAI conversion passes a pre-parsed object - const argType = typeof tc.function?.arguments; - if (tc.function?.arguments != null) { - acc.args += safeArgsString(tc.function.arguments); - } - dbg(` tc[${idx}] argType=${argType} id=${acc.id} name=${acc.name} args_so_far=${acc.args.slice(0, 100)}`); - } - } - - // ── Finish ───────────────────────────────────────────────────────────── - const finish = chunk?.choices?.[0]?.finish_reason; - if (finish) { - dbg(`FINISH finish_reason=${finish} toolCallKeys=${JSON.stringify(Object.keys(toolCallAccum))}`); - // Flush accumulated tool calls before stop - if (finish === "tool_calls") { - for (const acc of Object.values(toolCallAccum)) { - // IMPORTANT: Kiro's internal tool dispatcher expects `input` to be a JSON string - // (not a parsed object). The real CodeWhisperer server sends: - // { toolUseId, name, input: "{\"key\":\"value\"}" } ← input is a string - // Kiro then JSON.parses that string to get the tool arguments. - // If we send input as a parsed object, Kiro does String(obj) → "[object Object]". - const inputStr = acc.args || "{}"; - dbg(` toolUseEvent: id=${acc.id} name=${acc.name} inputStr=${inputStr.slice(0, 200)}`); - res.write(buildEventStreamFrame("toolUseEvent", { - toolUseId: acc.id, - name: acc.name, - input: inputStr, // Must be a JSON STRING, not a parsed object - })); - } - } - sendStop(); - } + // Handle tool calls — stream incrementally, matching real API format + if (delta.tool_calls) { + state.hasToolCalls = true; + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + + if (tc.id && tc.function?.name && !state.toolCallInit[idx]) { + // First appearance: emit frame with name + id, no input + state.toolCallInit[idx] = { id: tc.id, name: tc.function.name }; + dbg(`toolUseEvent init: ${tc.function.name} (${tc.id})`); + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.function.name, + toolUseId: tc.id + })); + } + + // Emit incremental input fragment + if (tc.function?.arguments) { + const init = state.toolCallInit[idx]; + dbg(`toolUseEvent fragment: ${tc.function.arguments.slice(0, 100)}`); + frames.push(buildEventStreamFrame("toolUseEvent", { + input: tc.function.arguments, + name: init?.name || tc.function?.name || "", + toolUseId: init?.id || tc.id || "" + })); } } - } finally { - sendStop(); - res.end(); } + + // Handle explicit reasoning_content (type-specific thinking channel) + if (delta.reasoning_content) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: delta.reasoning_content, + modelId + })); + } + + // Handle text content — extract thinking blocks, emit rest as assistantResponseEvent + if (delta.content) { + const { thinking, text } = extractThinking(delta.content, state); + + if (thinking) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId + })); + } + + if (text) { + frames.push(buildEventStreamFrame("assistantResponseEvent", { + content: text, + modelId + })); + } + } + + // Handle finish_reason + if (choice?.finish_reason) { + const finishFrames = emitFinish(state); + if (finishFrames) { + frames.push(...(Array.isArray(finishFrames) ? finishFrames : [finishFrames])); + } + } + + if (frames.length === 0) return null; + return frames.length === 1 ? frames[0] : frames; +} + +/** + * Emit termination frames. For tool-call responses, emits stop:true per tool. + * For text-only responses, emits messageStopEvent. + */ +function emitFinish(state) { + const frames = []; + + if (state.hasToolCalls) { + // Tool-call response: emit stop:true for each tool + for (const idx of Object.keys(state.toolCallInit).sort()) { + const tc = state.toolCallInit[idx]; + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.name, + stop: true, + toolUseId: tc.id + })); + } + } else { + // Text-only response: emit messageStopEvent + frames.push(buildEventStreamFrame("messageStopEvent", {})); + } + state.finishSent = true; + + // Emit usage if available + if (state.usage) { + frames.push(buildEventStreamFrame("usageEvent", { + inputTokens: state.usage.prompt_tokens || 0, + outputTokens: state.usage.completion_tokens || 0 + })); + } + + state.toolCallInit = {}; + return frames.length > 0 ? frames : null; } // ─── MITM intercept entry point ─────────────────────────────────────────────── /** - * Intercept Kiro IDE CodeWhisperer request: - * 1. Parse CodeWhisperer binary/JSON body - * 2. Convert to OpenAI messages[] format + * Intercept Kiro IDE CodeWhisperer request and convert to EventStream response: + * 1. Parse CodeWhisperer JSON body (reject binary EventStream formats) + * 2. Convert CodeWhisperer format to OpenAI messages[] format * 3. Forward to 9router /v1/chat/completions (OpenAI SSE) * 4. Convert OpenAI SSE response → AWS EventStream binary frames - * 5. Stream binary frames back to Kiro + * 5. Stream EventStream frames back to Kiro IDE + * + * @param {http.IncomingMessage} req - HTTP request from Kiro IDE + * @param {http.ServerResponse} res - HTTP response to Kiro IDE + * @param {Buffer} bodyBuffer - Request body buffer + * @param {string} mappedModel - Model name after MITM alias mapping */ async function intercept(req, res, bodyBuffer, mappedModel) { try { + // Detect and handle binary data (e.g., continuation requests with EventStream frames) + if (isBinaryEventStream(bodyBuffer)) { + // Binary EventStream requests are typically continuation/streaming frames + // that don't contain model info - pass them through directly to avoid JSON.parse crash + throw new Error(`Binary EventStream format detected (${bodyBuffer.length}B) - request should use passthrough instead of intercept`); + } + const body = JSON.parse(bodyBuffer.toString()); // 1 + 2: CodeWhisperer → OpenAI messages + tools @@ -380,22 +491,36 @@ async function intercept(req, res, bodyBuffer, mappedModel) { // 3: Forward to 9router const routerRes = await fetchRouter(openaiBody, "/v1/chat/completions", req.headers); - // 4 + 5: Re-encode response as AWS EventStream binary - res.writeHead(routerRes.status, { - "Content-Type": "application/vnd.amazon.eventstream", - "x-amzn-requestid": `mitm-${Date.now()}`, - "x-amz-id-2": "mitm", - "Transfer-Encoding": "chunked", - }); + // 4 + 5: Re-encode response as AWS EventStream binary using standard pipeline + const state = initKiroState(mappedModel); - await pipeOpenAIasEventStream(routerRes, res); + await pipeTransformedEventStream(routerRes, res, convertOpenAIToKiro, state); } catch (error) { - err(`[Kiro] ${error.message}`); + err(`[Kiro MITM] Request processing failed: ${error.message}`); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); } - res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + res.end(JSON.stringify({ + error: { + message: error.message, + type: "mitm_error", + handler: "kiro" + } + })); } } +// Detect AWS EventStream binary format +function isBinaryEventStream(buffer) { + if (!buffer || buffer.length < 12) return false; + // AWS EventStream signature: + // - First 4 bytes: total frame length (big-endian) + // - Bytes 4-8: headers length (big-endian) + // - Typical frame length: 100-10000 bytes + const totalLen = buffer.readUInt32BE(0); + const headersLen = buffer.readUInt32BE(4); + // Sanity checks: frame length should be reasonable and headers should fit + return totalLen > 12 && totalLen < 1000000 && headersLen < totalLen - 12; +} + module.exports = { intercept }; diff --git a/src/mitm/server.js b/src/mitm/server.js index 47b2326834a..2c5c876ac89 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,7 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); -const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, getToolForHost } = require("./config"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); @@ -95,6 +95,10 @@ function collectBodyRaw(req) { function extractModel(url, body) { const urlMatch = url.match(/\/models\/([^/:]+)/); if (urlMatch) return urlMatch[1]; + + // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) + if (isBinaryData(body)) return null; + try { const parsed = JSON.parse(body.toString()); if (parsed.conversationState) { @@ -104,13 +108,33 @@ function extractModel(url, body) { } catch { return null; } } +// Detect binary data vs JSON text +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) + // Check for non-printable chars in first 100 bytes (common in binary protocols) + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + // Count non-ASCII printable chars (excluding whitespace) + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + // If >30% non-printable, treat as binary + return (nonPrintable / sample.length) > 0.3; +} + function getMappedModel(tool, model) { if (!model) return null; try { const aliases = getMitmAlias(tool); if (!aliases) return null; - // Normalize via synonym map (e.g., gemini-default → gemini-3-flash) - const lookup = MODEL_SYNONYMS?.[tool]?.[model] || model; + // Normalize via synonym map (e.g., public AG names -> backend model ids) + const normalizedModel = String(model).replace(/^models\//, ""); + const lookup = MODEL_SYNONYMS?.[tool]?.[normalizedModel] || normalizedModel; if (aliases[lookup]) return aliases[lookup]; // Prefix match fallback const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); @@ -329,6 +353,14 @@ const server = https.createServer(sslOptions, async (req, res) => { } const model = extractModel(req.url, bodyBuffer); + + // Intentional passthrough: some models must never be re-routed (e.g. Antigravity + // tab-autocomplete) so latency-critical inline completion stays native. Silent — this + // is by design, not a leak, and fires per keystroke. See MODEL_NO_MAP in config.js. + if (model && (MODEL_NO_MAP[tool] || []).some((re) => re.test(model))) { + return passthrough(req, res, bodyBuffer); + } + const mappedModel = getMappedModel(tool, model); if (!mappedModel) { return passthrough(req, res, bodyBuffer); diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index de1bc052b80..f8427aa28a5 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -6,6 +6,7 @@ import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; import HeaderMenu from "@/shared/components/HeaderMenu"; +import HeaderLanguage from "@/shared/components/HeaderLanguage"; import ThemeToggle from "@/shared/components/ThemeToggle"; import DonateModal from "@/shared/components/DonateModal"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; @@ -315,6 +316,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) { Donate +
setDonateOpen(false)} /> diff --git a/src/shared/components/HeaderLanguage.js b/src/shared/components/HeaderLanguage.js new file mode 100644 index 00000000000..0e24d3c7bcd --- /dev/null +++ b/src/shared/components/HeaderLanguage.js @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; +import { LOCALE_FLAGS } from "@/shared/constants/locales"; +import LanguageSwitcher from "./LanguageSwitcher"; + +function getLocaleFromCookie() { + if (typeof document === "undefined") return "en"; + const cookie = document.cookie + .split(";") + .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); + const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; + return normalizeLocale(value); +} + +export default function HeaderLanguage() { + const [open, setOpen] = useState(false); + const [locale, setLocale] = useState("en"); + + useEffect(() => { + setLocale(getLocaleFromCookie()); + }, [open]); + + return ( + <> + + + { + setOpen(false); + setLocale(next); + }} + /> + + ); +} diff --git a/src/shared/components/HeaderMenu.js b/src/shared/components/HeaderMenu.js index 1f36be12777..54ba67636d3 100644 --- a/src/shared/components/HeaderMenu.js +++ b/src/shared/components/HeaderMenu.js @@ -2,56 +2,9 @@ import { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; -import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; import { useTheme } from "@/shared/hooks/useTheme"; import ChangelogModal from "./ChangelogModal"; -import NineRemotePromoModal from "./NineRemotePromoModal"; -import LanguageSwitcher from "./LanguageSwitcher"; - -const LOCALE_INFO = { - "en": { name: "English", flag: "🇺🇸" }, - "vi": { name: "Tiếng Việt", flag: "🇻🇳" }, - "zh-CN": { name: "简体中文", flag: "🇨🇳" }, - "zh-TW": { name: "繁體中文", flag: "🇹🇼" }, - "ja": { name: "日本語", flag: "🇯🇵" }, - "pt-BR": { name: "Português (BR)", flag: "🇧🇷" }, - "pt-PT": { name: "Português (PT)", flag: "🇵🇹" }, - "ko": { name: "한국어", flag: "🇰🇷" }, - "es": { name: "Español", flag: "🇪🇸" }, - "de": { name: "Deutsch", flag: "🇩🇪" }, - "fr": { name: "Français", flag: "🇫🇷" }, - "he": { name: "עברית", flag: "🇮🇱" }, - "ar": { name: "العربية", flag: "🇸🇦" }, - "ru": { name: "Русский", flag: "🇷🇺" }, - "pl": { name: "Polski", flag: "🇵🇱" }, - "cs": { name: "Čeština", flag: "🇨🇿" }, - "nl": { name: "Nederlands", flag: "🇳🇱" }, - "tr": { name: "Türkçe", flag: "🇹🇷" }, - "uk": { name: "Українська", flag: "🇺🇦" }, - "tl": { name: "Tagalog", flag: "🇵🇭" }, - "id": { name: "Indonesia", flag: "🇮🇩" }, - "th": { name: "ไทย", flag: "🇹🇭" }, - "hi": { name: "हिन्दी", flag: "🇮🇳" }, - "bn": { name: "বাংলা", flag: "🇧🇩" }, - "ur": { name: "اردو", flag: "🇵🇰" }, - "ro": { name: "Română", flag: "🇷🇴" }, - "sv": { name: "Svenska", flag: "🇸🇪" }, - "it": { name: "Italiano", flag: "🇮🇹" }, - "el": { name: "Ελληνικά", flag: "🇬🇷" }, - "hu": { name: "Magyar", flag: "🇭🇺" }, - "fi": { name: "Suomi", flag: "🇫🇮" }, - "da": { name: "Dansk", flag: "🇩🇰" }, - "no": { name: "Norsk", flag: "🇳🇴" }, -}; - -function getLocaleFromCookie() { - if (typeof document === "undefined") return "en"; - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); - const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; - return normalizeLocale(value); -} +import { ConfirmModal } from "./Modal"; function MenuItem({ icon, label, onClick, trailing, danger }) { return ( @@ -83,15 +36,21 @@ MenuItem.propTypes = { export default function HeaderMenu({ onLogout }) { const [isOpen, setIsOpen] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false); - const [remoteOpen, setRemoteOpen] = useState(false); - const [langOpen, setLangOpen] = useState(false); - const [locale, setLocale] = useState("en"); + const [shutdownOpen, setShutdownOpen] = useState(false); + const [isShuttingDown, setIsShuttingDown] = useState(false); const { toggleTheme, isDark } = useTheme(); const menuRef = useRef(null); - useEffect(() => { - setLocale(getLocaleFromCookie()); - }, [langOpen]); + const handleShutdown = async () => { + setIsShuttingDown(true); + try { + await fetch("/api/version/shutdown", { method: "POST" }); + } catch (e) { + // Expected to fail as server shuts down; ignore error + } + setIsShuttingDown(false); + setShutdownOpen(false); + }; useEffect(() => { const handleClickOutside = (e) => { @@ -125,21 +84,16 @@ export default function HeaderMenu({ onLogout }) { label="Change Log" onClick={() => { close(); setChangelogOpen(true); }} /> - { close(); setLangOpen(true); }} - /> { toggleTheme(); close(); }} /> { close(); setRemoteOpen(true); }} + icon="power_settings_new" + label="Shutdown" + danger + onClick={() => { close(); setShutdownOpen(true); }} /> setChangelogOpen(false)} /> - setRemoteOpen(false)} /> - { - setLangOpen(false) - setLocale(locale) - })} /> + setShutdownOpen(false)} + onConfirm={handleShutdown} + title="Close Proxy" + message="Are you sure you want to close the proxy server?" + confirmText="Close" + cancelText="Cancel" + variant="danger" + loading={isShuttingDown} + /> ); } diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index bfa18bdc12e..4c2e052c6b8 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -10,6 +10,7 @@ import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; +import NineRemotePromoModal from "./NineRemotePromoModal"; // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"]; @@ -41,8 +42,7 @@ const systemItems = [ export default function Sidebar({ onClose }) { const pathname = usePathname(); const [mediaOpen, setMediaOpen] = useState(false); - const [showShutdownModal, setShowShutdownModal] = useState(false); - const [isShuttingDown, setIsShuttingDown] = useState(false); + const [showRemoteModal, setShowRemoteModal] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); @@ -107,18 +107,6 @@ export default function Sidebar({ onClose }) { // user runs the command manually in another terminal. - const handleShutdown = async () => { - setIsShuttingDown(true); - try { - await fetch("/api/version/shutdown", { method: "POST" }); - } catch (e) { - // Expected to fail as server shuts down; ignore error - } - setIsShuttingDown(false); - setShowShutdownModal(false); - setIsDisconnected(true); - }; - return ( <> - {/* Shutdown Confirmation Modal */} - setShowShutdownModal(false)} - onConfirm={handleShutdown} - title="Close Proxy" - message="Are you sure you want to close the proxy server?" - confirmText="Close" - cancelText="Cancel" - variant="danger" - loading={isShuttingDown} - /> + {/* Remote Promo Modal */} + setShowRemoteModal(false)} /> {/* Update Confirmation Modal */} { await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, + ...newCreds, + existingProviderSpecificData: credentials.providerSpecificData, testStatus: "active" }); }, diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 49380948ab7..cde0d41ea50 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -114,9 +114,8 @@ export async function handleEmbeddings(request) { log, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, + ...newCreds, + existingProviderSpecificData: credentials.providerSpecificData, testStatus: "active" }); }, diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 456e20e4acf..ab45bc98c2d 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -179,6 +179,10 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu apiKey: connection.apiKey, accessToken: connection.accessToken, refreshToken: connection.refreshToken, + idToken: connection.idToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + lastRefreshAt: connection.lastRefreshAt, projectId: connection.projectId, connectionName: connection.displayName || connection.name || connection.email || connection.id, copilotToken: connection.providerSpecificData?.copilotToken, diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index 83bd7d7cf4c..05914e84050 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -23,6 +23,10 @@ import { refreshKiroToken as _refreshKiroToken, getRefreshLeadMs as _getRefreshLeadMs } from "open-sse/services/tokenRefresh.js"; +import { + refreshProviderCredentials as _refreshProviderCredentials, + shouldRefreshCredentials as _shouldRefreshCredentials, +} from "open-sse/services/oauthCredentialManager.js"; export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS; @@ -67,6 +71,9 @@ export const formatProviderCredentials = (provider, credentials) => export const getAllAccessTokens = (userInfo) => _getAllAccessTokens(userInfo, log); +export const shouldRefreshCredentials = (provider, credentials) => + _shouldRefreshCredentials(provider, credentials); + // ─── Lifecycle hook ─────────────────────────────────────────────────────────── /** @@ -158,6 +165,9 @@ export async function updateProviderCredentials(connectionId, newCredentials) { if (newCredentials.accessToken) updates.accessToken = newCredentials.accessToken; if (newCredentials.refreshToken) updates.refreshToken = newCredentials.refreshToken; + if (newCredentials.idToken) updates.idToken = newCredentials.idToken; + if (newCredentials.lastRefreshAt) updates.lastRefreshAt = newCredentials.lastRefreshAt; + if (newCredentials.expiresAt) updates.expiresAt = newCredentials.expiresAt; if (newCredentials.expiresIn) { updates.expiresAt = toExpiresAt(newCredentials.expiresIn); updates.expiresIn = newCredentials.expiresIn; @@ -174,6 +184,13 @@ export async function updateProviderCredentials(connectionId, newCredentials) { ...newCredentials.providerSpecificData, }; } + if (newCredentials.copilotToken || newCredentials.copilotTokenExpiresAt) { + updates.providerSpecificData = { + ...(updates.providerSpecificData || newCredentials.existingProviderSpecificData || {}), + ...(newCredentials.copilotToken ? { copilotToken: newCredentials.copilotToken } : {}), + ...(newCredentials.copilotTokenExpiresAt ? { copilotTokenExpiresAt: newCredentials.copilotTokenExpiresAt } : {}), + }; + } if (newCredentials.projectId) updates.projectId = newCredentials.projectId; const result = await updateProviderConnection(connectionId, updates); @@ -205,44 +222,41 @@ export async function checkAndRefreshToken(provider, credentials) { let creds = { ...credentials }; // ── 1. Regular access-token expiry ──────────────────────────────────────── - if (creds.expiresAt) { - const expiresAt = new Date(creds.expiresAt).getTime(); - const now = Date.now(); - const remaining = expiresAt - now; - + if (_shouldRefreshCredentials(provider, creds)) { + const expiresAt = creds.expiresAt ? new Date(creds.expiresAt).getTime() : null; + const remaining = expiresAt ? expiresAt - Date.now() : null; const refreshLead = _getRefreshLeadMs(provider); - if (remaining < refreshLead) { - log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", { - provider, - expiresIn: Math.round(remaining / 1000), - refreshLeadMs: refreshLead, - }); - const newCreds = await getAccessToken(provider, creds); - if (newCreds?.accessToken) { - const mergedCreds = { - ...newCreds, - existingProviderSpecificData: creds.providerSpecificData, - }; + log.info("TOKEN_REFRESH", "Refreshing provider credentials proactively", { + provider, + expiresIn: remaining === null ? null : Math.round(remaining / 1000), + refreshLeadMs: refreshLead, + lastRefreshAt: creds.lastRefreshAt || null, + }); - // Persist to DB (non-blocking path continues below) - await updateProviderCredentials(creds.connectionId, mergedCreds); - - creds = { - ...creds, - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken ?? creds.refreshToken, - providerSpecificData: newCreds.providerSpecificData - ? { ...creds.providerSpecificData, ...newCreds.providerSpecificData } - : creds.providerSpecificData, - expiresAt: newCreds.expiresIn - ? toExpiresAt(newCreds.expiresIn) - : normalizeExpiresAt(newCreds.expiresAt) || creds.expiresAt, - }; + const newCreds = await _refreshProviderCredentials(provider, creds, log); + if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) { + const mergedCreds = { + ...newCreds, + existingProviderSpecificData: creds.providerSpecificData, + }; - // Non-blocking: refresh projectId with the new access token - _refreshProjectId(provider, creds.connectionId, creds.accessToken); - } + // Persist to DB (non-blocking path continues below) + await updateProviderCredentials(creds.connectionId, mergedCreds); + + creds = { + ...creds, + ...newCreds, + expiresAt: newCreds.expiresIn + ? toExpiresAt(newCreds.expiresIn) + : normalizeExpiresAt(newCreds.expiresAt) || newCreds.expiresAt || creds.expiresAt, + providerSpecificData: newCreds.providerSpecificData + ? { ...creds.providerSpecificData, ...newCreds.providerSpecificData } + : creds.providerSpecificData, + }; + + // Non-blocking: refresh projectId with the new access token + _refreshProjectId(provider, creds.connectionId, creds.accessToken); } } diff --git a/tester/translator/testFromFile.js b/tester/translator/testFromFile.js deleted file mode 100755 index 298548e9e54..00000000000 --- a/tester/translator/testFromFile.js +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node - -/** - * Test sending request from converted file directly to provider - * Usage: - * node testFromFile.js - * node testFromFile.js data/claude-to-kiro/3_converted_request.json - */ - -const fs = require("fs"); -const path = require("path"); - -const args = process.argv.slice(2); - -if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { - console.log(""); - console.log("🧪 Test From File - Send converted request to provider"); - console.log(""); - console.log("Usage:"); - console.log(" node testFromFile.js "); - console.log(""); - console.log("Examples:"); - console.log(" node testFromFile.js data/claude-to-kiro/3_converted_request.json"); - console.log(" node testFromFile.js ../logs/openai_codex_xxx/3_converted_request.json"); - console.log(""); - console.log("File format:"); - console.log(" {"); - console.log(" \"url\": \"https://api.provider.com/...\","); - console.log(" \"headers\": { ... },"); - console.log(" \"body\": { ... }"); - console.log(" }"); - console.log(""); - process.exit(0); -} - -const filePath = args[0]; -const fullPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); - -if (!fs.existsSync(fullPath)) { - console.error(`❌ File not found: ${fullPath}`); - process.exit(1); -} - -// Load request data -let data; -try { - data = JSON.parse(fs.readFileSync(fullPath, "utf8")); -} catch (err) { - console.error(`❌ Failed to parse JSON: ${err.message}`); - process.exit(1); -} - -const { url, headers, body } = data; - -if (!url || !headers || !body) { - console.error("❌ Invalid file format. Expected: { url, headers, body }"); - process.exit(1); -} - -// Display request info -console.log("\n🚀 Sending Request from File\n"); -console.log(`📁 File: ${filePath}`); -console.log(`🌐 URL: ${url}`); -console.log(`📋 Headers:`); -Object.entries(headers).forEach(([k, v]) => { - if (k.toLowerCase().includes("auth") || k.toLowerCase().includes("key") || k.toLowerCase().includes("bearer")) { - const str = String(v); - if (str.length > 20) { - console.log(` ${k}: ${str.slice(0, 20)}...`); - } else { - console.log(` ${k}: ${str}`); - } - } else { - console.log(` ${k}: ${v}`); - } -}); - -console.log(`\n📊 Request Body:`); -console.log(` Model: ${body.model || "N/A"}`); -console.log(` Messages: ${body.messages?.length || 0}`); -console.log(` Tools: ${body.tools?.length || 0}`); -console.log(` Stream: ${body.stream || false}`); - -// Send request -(async () => { - try { - console.log("\n🚀 Sending request..."); - - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body) - }); - - console.log(`\n📥 Response: ${response.status} ${response.statusText}`); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`\n❌ Error response:\n${errorText}`); - process.exit(1); - } - - const isStreaming = body.stream || response.headers.get("content-type")?.includes("text/event-stream"); - - if (isStreaming) { - console.log("\n📡 Streaming response...\n"); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let chunkCount = 0; - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop(); // Keep incomplete line in buffer - - for (const line of lines) { - if (line.trim()) { - process.stdout.write(line + "\n"); - chunkCount++; - } - } - } - - // Process any remaining data - if (buffer.trim()) { - process.stdout.write(buffer + "\n"); - } - - console.log(`\n\n✅ Received ${chunkCount} chunks`); - } else { - const responseData = await response.json(); - console.log("\n📦 Response:"); - console.log(JSON.stringify(responseData, null, 2)); - } - - } catch (err) { - console.error("\n❌ Request failed:", err.message); - if (process.env.DEBUG) { - console.error(err.stack); - } - process.exit(1); - } -})(); diff --git a/tests/translator/AGENTS.md b/tests/translator/AGENTS.md new file mode 100644 index 00000000000..95273aa4485 --- /dev/null +++ b/tests/translator/AGENTS.md @@ -0,0 +1,120 @@ +# Translation Layer Tests + +Tests for `open-sse/translator/`. Goals: (1) data-driven coverage of every provider/model, (2) expose bugs caused by using OpenAI as the intermediate format. + +## 1. Translation layer structure (`open-sse/translator/`) + +Pipeline uses **OpenAI as the intermediate format**: +- Request: `source → openai → target` (`translateRequest`) +- Response (SSE chunk): `target → openai → source` (`translateResponse`) +- If `source === target` → translation is skipped (passthrough). + +Components: +- `index.js` — `translateRequest` / `translateResponse` / `register(from, to, requestFn, responseFn)` / registry. +- `formats.js` — `FORMATS` enum (openai, claude, gemini, gemini-cli, openai-responses, antigravity, kiro, cursor, commandcode, ollama, vertex). +- `request/-to-.js` — one-way request translation. +- `response/-to-.js` — one-way SSE response translation. +- `helpers/` — `openaiHelper.js` (filterToOpenAIFormat), `toolCallHelper.js` (id/arguments), `claudeHelper.js`, `geminiHelper.js`. + +**OpenAI-bridge pitfalls** (source of most bugs): going through OpenAI easily loses `thinking`/`reasoning`, image URLs (non-base64), `input_audio`, `is_error`; tool `id`/`index` become unstable (parallel tool calls), non-text system blocks, `tool_choice:"none"`. + +## 2. Test layout + +| File | Role | +|---|---| +| `matrix.js` | Reads `PROVIDER_MODELS` → builds matrix (alias, model, targetFormat, strip, upstreamId). DRY core. | +| `registerAll.js` | Imports every translator to run `register()` side-effects. **Required** (see §5). | +| `coverage-all-models.test.js` | Tier 1: every model translates without throwing; strip applied correctly. | +| `format-roundtrip.test.js` | Tier 2: tool id/system/parallel survive the bridge. | +| `bugs-openai-bridge.test.js` | Exposes concrete bugs (with source file:line). | + +## 3. Running + +Always pass `--config tests/vitest.config.js` (the alias config lives there; without it vitest may not resolve `@/...` subpaths). + +```bash +# no-cred (default, offline): translator-only files +cd app && npx vitest run --config tests/vitest.config.js "tests/translator/" +cd app && npx vitest run --config tests/vitest.config.js "tests/translator/bugs-openai-bridge.test.js" + +# real (calls live providers using credentials from the local DB) +cd app && RUN_REAL=1 npx vitest run --config tests/vitest.config.js "tests/translator/real/" +``` +No-cred tests make NO network calls and need NO creds. Real tests (`real/`, gated by `RUN_REAL=1`) read active connections from `~/.9router/db/data.sqlite`, send a tiny prompt per provider through `handleChatCore`, and assert valid SSE. Account/quota errors (401/402/403/429) are treated as credential issues and skipped, not failures. + +## 4. Adding a new provider → tests cover it AUTOMATICALLY + +Add a provider by adding a key to `open-sse/config/providerModels.js` `PROVIDER_MODELS` (e.g. `newprov: [{ id, targetFormat?, strip?, upstreamModelId? }]`) plus its config in `open-sse/config/providers.js`. + +→ `coverage-all-models.test.js` **automatically** runs for the new models with **no test edits**. `matrix.js` reads config directly. + +Only add a dedicated test when a provider has a special format that does not round-trip cleanly (see §7). + +## 5. `registerAll.js` — why it is required + +`translator/index.js` uses `require(...)` (bundler-only) to lazy-load translators. Under vitest/ESM, `require` **silently no-ops** → empty registry → `translateRequest` skips the translation step → **false pass** (data is lost but the test goes green by mistake). + +→ Every test calling `translateRequest`/`translateResponse` MUST `import "./registerAll.js"` at the top of the file. + +## 6. Bug-exposure convention — `it.fails` + +- A bug confirmed in the app but NOT yet fixed → use `it.fails(...)`. +- `it.fails` **passes while the app still has the bug**, **turns red once the bug is fixed** → a reminder to update the test (switch `it.fails` → `it` and confirm correct behavior). +- Pattern for a new bug-exposure test: real input → assert the "should-be-kept" behavior → wrap in `it.fails` + a comment with the source `file:line`. + +## 7. Special formats to watch + +- `kiro` (binary AWS EventStream), `cursor` (protobuf ConnectRPC), `commandcode` (NDJSON) → responses do NOT round-trip cleanly through openai; test via their executors, not just the translator. +- Single-provider-two-formats (most fragile): `opencode-go` (minimax models → claude, others openai), `github` (escalates `/chat/completions` → `/responses` at runtime), `xiaomi-tokenplan` (claude alias). +- `gemini`/`gemini-cli`: only the LAST system message is kept → earlier system messages are lost. + +## 8. Current known bugs (currently `it.fails`) + +Grouped per CLI/provider test file. Each row is an `it.fails` case. + +**Claude (`bugs-openai-bridge.test.js`, `bugs-claudeCode-context.test.js`)** +| Bug | Source | +|---|---| +| Claude image `source.type="url"` dropped (only base64) | `request/claude-to-openai.js:133-141` | +| `tool_result` image block → raw JSON | `request/claude-to-openai.js:155-173` | +| `tool_result.is_error` lost | `request/claude-to-openai.js:155-173` | +| `thinking`/`redacted_thinking` dropped via bridge | `request/claude-to-openai.js:128` | + +**OpenAI → Claude (`bugs-toClaude-context.test.js`)** +| Bug | Source | +|---|---| +| Always injects "You are Claude Code" system prompt | `request/openai-to-claude.js:124-134` | +| `reasoning_content` not mapped to a thinking block | `request/openai-to-claude.js:268-273` | +| `tool_choice:"none"` → `auto` | `request/openai-to-claude.js:298` | +| `input_audio` dropped | `request/openai-to-claude.js` (no audio branch) | + +**Codex Responses (`bugs-codexCli-responses.test.js`)** +| Bug | Source | +|---|---| +| Empty-name function_call can leave `tool_calls: []` | `request/openai-responses.js:103` | +| `arguments` not coerced to string | `request/openai-responses.js:109-110` | +| `input_image` uses `file_id` as raw url | `request/openai-responses.js:75-77` | + +**Antigravity (`bugs-antigravity.test.js`)** +| Bug | Source | +|---|---| +| functionResponse + functionCall in same content → tool calls dropped | `request/antigravity-to-openai.js:177-189` | +| functionCall without id → random unstable id | `request/antigravity-to-openai.js:167` | + +**Kiro (`bugs-kiro.test.js`)** +| Bug | Source | +|---|---| +| `JSON.parse(arguments)` throws on bad JSON (no try/catch) | `request/openai-to-kiro.js:214-216` | +| `max_tokens` hardcoded to 32000 | `request/openai-to-kiro.js:309` | +| Remote image → `[Image: url]` text | `request/openai-to-kiro.js:132-134` | + +**Gemini / Cursor / CommandCode (`bugs-gemini-cursor-commandcode.test.js`)** +| Bug | Source | +|---|---| +| Only the last system message kept | `request/openai-to-gemini.js:92-96` | +| Cursor drops image content | `request/openai-to-cursor.js:12-24` | +| Cursor `max_tokens` hardcoded to 32000 | `request/openai-to-cursor.js:179` | +| CommandCode bad JSON args → `{}` silently | `request/openai-to-commandcode.js:53-57` | +| CommandCode image → `[image omitted]` | `request/openai-to-commandcode.js:41-42` | + +Fixing a bug → rerun; the matching `it.fails` test turns RED → switch it to a regular `it` and verify correct behavior. diff --git a/tests/translator/bugs-antigravity.test.js b/tests/translator/bugs-antigravity.test.js new file mode 100644 index 00000000000..6b8052adfdf --- /dev/null +++ b/tests/translator/bugs-antigravity.test.js @@ -0,0 +1,54 @@ +// Real Antigravity-MITM requests (Gemini-internal: { request: { contents, ... } }) → OpenAI. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const AG2O = (req) => + translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null); + +describe("Antigravity → OpenAI", () => { + // antigravity-to-openai.js:177-189 — content with BOTH functionResponse and functionCall/text + // returns toolResults early → drops the tool calls / text. + // KNOWN BUG + it.fails("functionResponse + functionCall in same content keeps both", () => { + const out = AG2O({ + contents: [{ + role: "model", + parts: [ + { functionResponse: { id: "c1", name: "prev", response: { result: "done" } } }, + { functionCall: { id: "c2", name: "next", args: {} } }, + ], + }], + }); + const json = JSON.stringify(out); + expect(json, "functionCall lost when sharing content with functionResponse").toContain("\"next\""); + }); + + // antigravity-to-openai.js:167 — functionCall without id gets a random Date.now() id + // KNOWN BUG: unstable id breaks matching with its functionResponse + it.fails("functionCall without id keeps a stable matchable id", () => { + const out = AG2O({ + contents: [ + { role: "model", parts: [{ functionCall: { name: "search", args: { q: "x" } } }] }, + { role: "user", parts: [{ functionResponse: { name: "search", response: { result: "r" } } }] }, + ], + }); + const asst = out.messages.find((m) => m.tool_calls); + const tool = out.messages.find((m) => m.role === "tool"); + expect(tool?.tool_call_id, "id mismatch between call and response").toBe(asst?.tool_calls?.[0]?.id); + }); + + // antigravity-to-openai.js:144-147 — signature-only part handling (regression guard) + it("signature-only part does not produce empty text", () => { + const out = AG2O({ + contents: [{ role: "model", parts: [{ thoughtSignature: "sig", text: "" }] }], + }); + const asst = out.messages.find((m) => m.role === "assistant"); + const content = asst?.content; + const hasEmpty = Array.isArray(content) + ? content.some((c) => c.type === "text" && c.text === "") + : content === ""; + expect(hasEmpty, "empty text part emitted").toBe(false); + }); +}); diff --git a/tests/translator/bugs-claudeCode-context.test.js b/tests/translator/bugs-claudeCode-context.test.js new file mode 100644 index 00000000000..eb782114623 --- /dev/null +++ b/tests/translator/bugs-claudeCode-context.test.js @@ -0,0 +1,73 @@ +// Real Claude Code CLI requests (Claude format) → non-Claude provider via OpenAI bridge. +// Focuses on context components a real CLI sends: system arrays w/ cache_control, thinking +// signatures, tool_result with images, audio. KNOWN BUG = it.fails (source file:line in comments). +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const T = (src, tgt, body, provider = null) => + translateRequest(src, tgt, "m", body, true, null, provider); + +describe("Claude Code CLI context → OpenAI", () => { + // claude-to-openai.js:24-27 — system array only maps .text; cache_control/non-text dropped + it("system array keeps all text parts", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + system: [ + { type: "text", text: "You are Claude Code.", cache_control: { type: "ephemeral" } }, + { type: "text", text: "Follow repo conventions." }, + ], + messages: [{ role: "user", content: "hi" }], + }); + const sys = out.messages.find((m) => m.role === "system"); + expect(sys?.content).toContain("Claude Code"); + expect(sys?.content).toContain("repo conventions"); + }); + + // claude→claude is passthrough (same format) → thinking preserved. Guards against + // accidental routing through the OpenAI bridge for same-format requests. + it("assistant thinking block survives Claude→Claude passthrough", () => { + const out = T(FORMATS.CLAUDE, FORMATS.CLAUDE, { + messages: [ + { role: "assistant", content: [ + { type: "thinking", thinking: "step-by-step plan", signature: "abc123" }, + { type: "text", text: "done" }, + ] }, + { role: "user", content: "next" }, + ], + }); + expect(JSON.stringify(out)).toContain("step-by-step plan"); + }); + + // claude-to-openai.js:128 — redacted_thinking also dropped + // KNOWN BUG + it.fails("redacted_thinking block is not silently dropped", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [ + { role: "assistant", content: [ + { type: "redacted_thinking", data: "ENCRYPTED_BLOB" }, + { type: "text", text: "answer" }, + ] }, + { role: "user", content: "go" }, + ], + }); + expect(JSON.stringify(out)).toContain("ENCRYPTED_BLOB"); + }); + + // claude-to-openai.js:155-173 — tool_result image block stringified into raw JSON + // KNOWN BUG + it.fails("tool_result image block is preserved", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "screenshot", input: {} }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "call_1", content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data: "IMG" } }, + ] }, + ] }, + ], + }); + const tool = out.messages.find((m) => m.role === "tool"); + expect(tool?.content, "image turned into raw JSON").not.toMatch(/^\[/); + }); +}); diff --git a/tests/translator/bugs-codexCli-responses.test.js b/tests/translator/bugs-codexCli-responses.test.js new file mode 100644 index 00000000000..6f947833134 --- /dev/null +++ b/tests/translator/bugs-codexCli-responses.test.js @@ -0,0 +1,63 @@ +// Real Codex CLI requests (OpenAI Responses API: { input:[], instructions }) → providers. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const R2O = (body) => translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, true, null, null); +const O2R = (body) => translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, "m", body, true, null, null); + +describe("Codex CLI Responses → OpenAI", () => { + // openai-responses.js:103 — function_call with empty name skipped, can leave tool_calls: [] + // KNOWN BUG: empty tool_calls array is rejected by OpenAI/Codex + it.fails("assistant has no empty tool_calls array when all names are empty", () => { + const out = R2O({ + input: [ + { type: "function_call", call_id: "c1", name: "", arguments: "{}" }, + ], + }); + const asst = out.messages.find((m) => m.role === "assistant" && m.tool_calls); + expect(asst?.tool_calls?.length ?? 0, "empty tool_calls[] produced").toBeGreaterThan(0); + }); + + // openai-responses.js:109-110 — arguments passed through without ensuring string type + // KNOWN BUG + it.fails("function_call arguments end up as a string", () => { + const out = R2O({ + input: [{ type: "function_call", call_id: "c1", name: "f", arguments: { a: 1 } }], + }); + const asst = out.messages.find((m) => m.tool_calls); + expect(typeof asst.tool_calls[0].function.arguments).toBe("string"); + }); + + // openai-responses.js:75-77 — input_image uses file_id as raw url + // KNOWN BUG + it.fails("input_image with file_id is not used as a raw url", () => { + const out = R2O({ + input: [{ type: "message", role: "user", content: [ + { type: "input_image", file_id: "file-abc" }, + ] }], + }); + const userMsg = out.messages.find((m) => m.role === "user"); + const img = Array.isArray(userMsg?.content) ? userMsg.content.find((c) => c.type === "image_url") : null; + // A bare file_id is not a valid image URL + expect(img?.image_url?.url === "file-abc").toBe(false); + }); +}); + +describe("OpenAI → Codex Responses (reverse)", () => { + // openai-responses.js:13 — clampCallId NOT applied on Responses→Chat; but here Chat→Responses must clamp + it("call_id longer than 64 chars is clamped", () => { + const longId = "call_" + "x".repeat(80); + const out = O2R({ + messages: [ + { role: "assistant", content: null, tool_calls: [ + { id: longId, type: "function", function: { name: "f", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: longId, content: "ok" }, + ], + }); + const fc = out.input.find((i) => i.type === "function_call"); + expect(fc.call_id.length).toBeLessThanOrEqual(64); + }); +}); diff --git a/tests/translator/bugs-gemini-cursor-commandcode.test.js b/tests/translator/bugs-gemini-cursor-commandcode.test.js new file mode 100644 index 00000000000..4b74c60e8ec --- /dev/null +++ b/tests/translator/bugs-gemini-cursor-commandcode.test.js @@ -0,0 +1,76 @@ +// OpenAI → Gemini / Cursor / CommandCode request translation. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const O2G = (body) => translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "m", body, true, null, "gemini"); +const O2C = (body) => translateRequest(FORMATS.OPENAI, FORMATS.CURSOR, "m", body, true, null, "cursor"); +const O2CC = (body) => translateRequest(FORMATS.OPENAI, FORMATS.COMMANDCODE, "m", body, true, null, "commandcode"); + +describe("OpenAI → Gemini", () => { + // openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept + // KNOWN BUG + it.fails("multiple system messages are all kept", () => { + const out = O2G({ + messages: [ + { role: "system", content: "RULE_ONE" }, + { role: "system", content: "RULE_TWO" }, + { role: "user", content: "hi" }, + ], + }); + expect(JSON.stringify(out.systemInstruction), "earlier system lost").toContain("RULE_ONE"); + }); +}); + +describe("OpenAI → Cursor", () => { + // openai-to-cursor.js:12-24 — image content fully dropped (text only) + // KNOWN BUG + it.fails("image content is preserved", () => { + const out = O2C({ + messages: [{ role: "user", content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ] }], + }); + expect(JSON.stringify(out), "image dropped").toContain("AAAA"); + }); + + // openai-to-cursor.js:179 — max_tokens hardcoded to 32000 + // KNOWN BUG + it.fails("respects client max_tokens", () => { + const out = O2C({ max_tokens: 200, messages: [{ role: "user", content: "hi" }] }); + expect(out.max_tokens).toBe(200); + }); +}); + +describe("OpenAI → CommandCode", () => { + // openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost) + // KNOWN BUG + it.fails("malformed tool arguments are not silently emptied", () => { + const out = O2CC({ + messages: [ + { role: "user", content: "go" }, + { role: "assistant", content: "", tool_calls: [ + { id: "c1", type: "function", function: { name: "f", arguments: "{bad" } }, + ] }, + { role: "tool", tool_call_id: "c1", content: "r" }, + ], + }); + const asst = out.params.messages.find((m) => m.role === "assistant"); + const call = asst.content.find((b) => b.type === "tool-call"); + expect(Object.keys(call.input).length, "arguments silently dropped to {}").toBeGreaterThan(0); + }); + + // openai-to-commandcode.js:41-42 — image becomes "[image omitted]" + // KNOWN BUG + it.fails("image content is preserved", () => { + const out = O2CC({ + messages: [{ role: "user", content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: "data:image/png;base64,BBBB" } }, + ] }], + }); + expect(JSON.stringify(out), "image omitted").toContain("BBBB"); + }); +}); diff --git a/tests/translator/bugs-kiro.test.js b/tests/translator/bugs-kiro.test.js new file mode 100644 index 00000000000..7f3677facc2 --- /dev/null +++ b/tests/translator/bugs-kiro.test.js @@ -0,0 +1,44 @@ +// OpenAI → Kiro (AWS CodeWhisperer) request translation. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const O2K = (body) => translateRequest(FORMATS.OPENAI, FORMATS.KIRO, "m", body, true, null, "kiro"); + +describe("OpenAI → Kiro", () => { + // openai-to-kiro.js — safeJSONParse guards bad tool-call JSON (fixed in PR #1582) + it("malformed tool arguments do not throw the whole request", () => { + expect(() => + O2K({ + messages: [ + { role: "user", content: "go" }, + { role: "assistant", content: "", tool_calls: [ + { id: "c1", type: "function", function: { name: "f", arguments: "{not json" } }, + ] }, + { role: "tool", tool_call_id: "c1", content: "r" }, + ], + }) + ).not.toThrow(); + }); + + // openai-to-kiro.js:309 — maxTokens hardcoded to 32000, ignores body.max_tokens + // KNOWN BUG + it.fails("respects client max_tokens", () => { + const out = O2K({ max_tokens: 100, messages: [{ role: "user", content: "hi" }] }); + expect(out.inferenceConfig?.maxTokens, "client max_tokens ignored").toBe(100); + }); + + // openai-to-kiro.js:132-134 — remote http image becomes "[Image: url]" text (lost) + // KNOWN BUG + it.fails("remote image url is preserved as an image, not text", () => { + const out = O2K({ + messages: [{ role: "user", content: [ + { type: "text", text: "see" }, + { type: "image_url", image_url: { url: "https://x.com/p.png" } }, + ] }], + }); + const content = out.conversationState?.currentMessage?.userInputMessage?.content || ""; + expect(content, "remote image flattened to text").not.toContain("[Image:"); + }); +}); diff --git a/tests/translator/bugs-openai-bridge.test.js b/tests/translator/bugs-openai-bridge.test.js new file mode 100644 index 00000000000..0214f5ed8ed --- /dev/null +++ b/tests/translator/bugs-openai-bridge.test.js @@ -0,0 +1,120 @@ +// Expose bugs caused by OpenAI being the intermediate format: data lost/wrong on source → openai → target. +// Each test describes the EXPECTED-correct behavior. A FAIL is evidence of the bug (with source file:line). +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const T = (src, tgt, body, provider = null) => + translateRequest(src, tgt, "m", body, true, null, provider); + +describe("bug: Claude → OpenAI bridge data loss", () => { + // claude-to-openai.js:133-141 — image source.type==="url" only handles base64 + // KNOWN BUG: it.fails passes while app drops the url; flips to failing once fixed. + it.fails("image with source.type=url is preserved (NOT dropped)", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [{ role: "user", content: [ + { type: "text", text: "look" }, + { type: "image", source: { type: "url", url: "https://x.com/a.png" } }, + ] }], + }); + const json = JSON.stringify(out); + expect(json, "remote image url silently dropped").toContain("a.png"); + }); + + // claude-to-openai.js:128 switch — missing thinking/redacted_thinking case + it("thinking block survives round-trip Claude→OpenAI→Claude", () => { + const body = { + messages: [{ role: "assistant", content: [ + { type: "thinking", thinking: "secret reasoning", signature: "sig" }, + { type: "text", text: "answer" }, + ] }, { role: "user", content: "go" }], + }; + const out = T(FORMATS.CLAUDE, FORMATS.CLAUDE, body); + const json = JSON.stringify(out); + expect(json, "thinking content lost via OpenAI bridge").toContain("secret reasoning"); + }); + + // claude-to-openai.js:155-173 — tool_result image block dropped (text only) + // KNOWN BUG + it.fails("tool_result with image block is not turned into raw JSON / dropped", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [ + { role: "assistant", content: [ + { type: "tool_use", id: "call_1", name: "shot", input: {} }, + ] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "call_1", content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data: "ZZZ" } }, + ] }, + ] }, + ], + }); + const toolMsg = out.messages.find((m) => m.role === "tool"); + // Should keep the image; currently stringifies the whole array into raw JSON + expect(toolMsg?.content, "image in tool_result lost").not.toMatch(/^\[/); + }); + + // claude-to-openai.js:155-173 — is_error lost + // KNOWN BUG + it.fails("tool_result is_error flag is preserved", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "f", input: {} }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "call_1", is_error: true, content: "boom" }, + ] }, + ], + }); + const json = JSON.stringify(out); + expect(json, "is_error dropped → model can't see tool failure").toContain("is_error"); + }); + + // claude-to-openai.js:24-27 — system array only takes .text, drops cache_control/non-text + it("system array non-text parts are not silently dropped", () => { + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + system: [ + { type: "text", text: "rule1", cache_control: { type: "ephemeral" } }, + { type: "text", text: "rule2" }, + ], + messages: [{ role: "user", content: "hi" }], + }); + const sys = out.messages.find((m) => m.role === "system"); + expect(sys?.content).toContain("rule1"); + expect(sys?.content).toContain("rule2"); + }); +}); + +describe("bug: tool_call id stability across bridge", () => { + // toolCallHelper.js:29-31 — sanitize changes tc.id but tool_call_id in another message may drift + it("sanitized tool id stays matched between call and result", () => { + const out = T(FORMATS.OPENAI, FORMATS.OPENAI, { + messages: [ + { role: "assistant", tool_calls: [ + { id: "call/with:bad*chars", type: "function", function: { name: "f", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "call/with:bad*chars", content: "ok" }, + ], + }); + const asst = out.messages.find((m) => m.role === "assistant"); + const tool = out.messages.find((m) => m.role === "tool"); + expect(tool.tool_call_id, "id mismatch after sanitize").toBe(asst.tool_calls[0].id); + }); +}); + +describe("bug: empty content message handling", () => { + // openaiHelper.js:49-51,66-71 — empty content → {text:""} then filtered out + it("assistant message with only tool_calls is not dropped", () => { + const out = T(FORMATS.OPENAI, FORMATS.OPENAI, { + messages: [ + { role: "user", content: "do it" }, + { role: "assistant", content: "", tool_calls: [ + { id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "call_1", content: "done" }, + ], + }); + const asst = out.messages.find((m) => m.role === "assistant" && m.tool_calls); + expect(asst, "assistant tool_calls message dropped").toBeTruthy(); + }); +}); diff --git a/tests/translator/bugs-toClaude-context.test.js b/tests/translator/bugs-toClaude-context.test.js new file mode 100644 index 00000000000..535d4c4f076 --- /dev/null +++ b/tests/translator/bugs-toClaude-context.test.js @@ -0,0 +1,65 @@ +// OpenAI-format CLI → Claude provider. Context pollution + lossy mapping on the openai→claude leg. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +// anthropic-compatible provider so prepareClaudeRequest runs the openai→claude path +const T = (body) => + translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, "m", body, true, null, "anthropic-compatible-x"); + +describe("OpenAI → Claude context mapping", () => { + // openai-to-claude.js:124-134 — always injects CLAUDE_SYSTEM_PROMPT ("You are Claude Code") + // KNOWN BUG: pollutes requests for non-official Claude-compatible providers + it.fails("does not inject Claude Code system prompt for compatible providers", () => { + const out = T({ messages: [{ role: "user", content: "hi" }] }); + expect(JSON.stringify(out.system), "Claude Code prompt injected").not.toContain("Claude Code"); + }); + + // openai-to-claude.js:268-273 — assistant.reasoning_content not mapped to a thinking block + // KNOWN BUG + it.fails("assistant reasoning_content becomes a thinking block", () => { + const out = T({ + messages: [ + { role: "user", content: "q" }, + { role: "assistant", content: "a", reasoning_content: "my hidden reasoning" }, + { role: "user", content: "next" }, + ], + }); + expect(JSON.stringify(out), "reasoning_content lost").toContain("my hidden reasoning"); + }); + + // openai-to-claude.js:298 — tool_choice "none" mapped to {type:"auto"} (loses "do not call" intent) + // KNOWN BUG + it.fails("tool_choice=none is not turned into auto", () => { + const out = T({ + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f", parameters: { type: "object", properties: {} } } }], + tool_choice: "none", + }); + expect(out.tool_choice?.type, "none became auto → model may call tools").not.toBe("auto"); + }); + + // getContentBlocksFromMessage — no input_audio branch → audio dropped + // KNOWN BUG + it.fails("input_audio content is preserved", () => { + const out = T({ + messages: [{ role: "user", content: [ + { type: "text", text: "transcribe" }, + { type: "input_audio", input_audio: { data: "AUDIO_B64", format: "wav" } }, + ] }], + }); + expect(JSON.stringify(out), "audio dropped").toContain("AUDIO_B64"); + }); + + // openai-to-claude.js:235-251 — remote http image_url is kept (regression guard) + it("remote http image_url is preserved", () => { + const out = T({ + messages: [{ role: "user", content: [ + { type: "text", text: "see" }, + { type: "image_url", image_url: { url: "https://x.com/pic.png" } }, + ] }], + }); + expect(JSON.stringify(out), "remote image dropped").toContain("pic.png"); + }); +}); diff --git a/tests/translator/coverage-all-models.test.js b/tests/translator/coverage-all-models.test.js new file mode 100644 index 00000000000..b4ef5bd62b4 --- /dev/null +++ b/tests/translator/coverage-all-models.test.js @@ -0,0 +1,53 @@ +// Tier 1 — Structural coverage: every model in PROVIDER_MODELS must translate +// without throwing, correct upstreamId, strip applied. Data-driven → new providers auto-covered. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { buildProviderGroups, buildModelMatrix, resolveTargetFormat } from "./matrix.js"; + +// Base OpenAI-format request with text + tool + image (exercises strip + tool paths) +function baseBody(modelId) { + return { + model: modelId, + stream: true, + max_tokens: 64, + messages: [ + { role: "system", content: "You are a helper." }, + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ], + }, + ], + tools: [ + { type: "function", function: { name: "get_time", description: "x", parameters: { type: "object", properties: {} } } }, + ], + }; +} + +const groups = buildProviderGroups(); + +describe("coverage: every model translates without throwing", () => { + it.each(groups)("$alias: all models OpenAI→target", ({ alias, models }) => { + for (const m of models) { + const target = resolveTargetFormat(alias, m.id); + const body = baseBody(m.id); + // source = openai (lingua franca); exercise openai → target path + const out = translateRequest(FORMATS.OPENAI, target, m.id, body, true, null, alias); + expect(out, `${alias}/${m.id} → ${target} returned falsy`).toBeTruthy(); + } + }); +}); + +const stripModels = buildModelMatrix().filter((r) => r.strip.includes("image")); +describe.skipIf(stripModels.length === 0)("coverage: image-strip models drop image content", () => { + it.each(stripModels)("$alias/$modelId strips image when strip=[image]", (row) => { + const body = baseBody(row.modelId); + const out = translateRequest(FORMATS.OPENAI, row.targetFormat, row.modelId, body, true, null, row.alias, null, row.strip); + const json = JSON.stringify(out); + expect(json).not.toContain("data:image/png"); + }); +}); diff --git a/tests/translator/format-roundtrip.test.js b/tests/translator/format-roundtrip.test.js new file mode 100644 index 00000000000..efc334d2526 --- /dev/null +++ b/tests/translator/format-roundtrip.test.js @@ -0,0 +1,76 @@ +// Tier 2 — Format-pair: for each CLI source format, translate to openai and verify +// core parts (text, tool, system) survive. Exposes bridge data loss. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +const T = (src, tgt, body, provider = null) => + translateRequest(src, tgt, "m", body, true, null, provider); + +describe("roundtrip: Claude source preserves core fields → OpenAI", () => { + const body = { + system: "sys", + max_tokens: 100, + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "search", input: { q: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "result" }] }, + ], + }; + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, body); + + it("system → system role", () => { + expect(out.messages.some((m) => m.role === "system" && m.content === "sys")).toBe(true); + }); + it("tool_use → assistant.tool_calls with matching id", () => { + const asst = out.messages.find((m) => m.tool_calls); + expect(asst?.tool_calls?.[0]?.id).toBe("call_1"); + }); + it("tool_result → tool message with matching id", () => { + const tool = out.messages.find((m) => m.role === "tool"); + expect(tool?.tool_call_id).toBe("call_1"); + expect(tool?.content).toContain("result"); + }); + it("tool arguments are valid JSON string", () => { + const asst = out.messages.find((m) => m.tool_calls); + expect(() => JSON.parse(asst.tool_calls[0].function.arguments)).not.toThrow(); + }); +}); + +describe("roundtrip: OpenAI tools → Claude → keeps tool name", () => { + const out = T(FORMATS.OPENAI, FORMATS.CLAUDE, { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "my_tool", description: "d", parameters: { type: "object", properties: {} } } }], + }, "anthropic-compatible-x"); + + it("tool name survives openai→claude", () => { + expect(JSON.stringify(out)).toContain("my_tool"); + }); +}); + +describe("roundtrip: parallel tool calls keep distinct ids", () => { + // Claude assistant with 2 parallel tool_use → openai must keep 2 distinct ids + const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, { + messages: [ + { role: "assistant", content: [ + { type: "tool_use", id: "call_a", name: "f1", input: {} }, + { type: "tool_use", id: "call_b", name: "f2", input: {} }, + ] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "call_a", content: "ra" }, + { type: "tool_result", tool_use_id: "call_b", content: "rb" }, + ] }, + ], + }); + + it("two tool_calls, two distinct ids", () => { + const asst = out.messages.find((m) => m.tool_calls); + const ids = asst.tool_calls.map((tc) => tc.id); + expect(new Set(ids).size).toBe(2); + }); + it("each tool_call has a matching tool result", () => { + const toolMsgs = out.messages.filter((m) => m.role === "tool"); + expect(toolMsgs.length).toBe(2); + }); +}); diff --git a/tests/translator/matrix.js b/tests/translator/matrix.js new file mode 100644 index 00000000000..bc3a4e0ec87 --- /dev/null +++ b/tests/translator/matrix.js @@ -0,0 +1,67 @@ +// Data-driven test matrix built from PROVIDER_MODELS (single source of truth). +// Adding a new provider/model to config auto-extends coverage — no test edits needed. +import { + PROVIDER_MODELS, + PROVIDER_ID_TO_ALIAS, + getModelTargetFormat, + getModelStrip, + getModelUpstreamId, +} from "../../open-sse/config/providerModels.js"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +// Reverse alias → providerId to resolve provider-level config.format +const ALIAS_TO_PROVIDER_ID = Object.fromEntries( + Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id]) +); + +// Provider-level format fallback (mirrors getTargetFormat without compat-url logic) +function providerFormat(alias) { + const providerId = ALIAS_TO_PROVIDER_ID[alias] || alias; + return PROVIDERS[providerId]?.format || FORMATS.OPENAI; +} + +// Resolve effective target format for an (alias, model): model override → provider format +export function resolveTargetFormat(alias, modelId) { + return getModelTargetFormat(alias, modelId) || providerFormat(alias); +} + +// Flat matrix of every model across every provider +export function buildModelMatrix() { + const rows = []; + for (const [alias, models] of Object.entries(PROVIDER_MODELS)) { + if (!Array.isArray(models)) continue; + for (const m of models) { + rows.push({ + alias, + providerId: ALIAS_TO_PROVIDER_ID[alias] || alias, + modelId: m.id, + type: m.type || "llm", + targetFormat: resolveTargetFormat(alias, m.id), + strip: getModelStrip(alias, m.id), + upstreamId: getModelUpstreamId(alias, m.id), + }); + } + } + return rows; +} + +// Distinct provider list (one representative llm model each) for grouped assertions +export function buildProviderGroups() { + const groups = []; + for (const [alias, models] of Object.entries(PROVIDER_MODELS)) { + if (!Array.isArray(models) || models.length === 0) continue; + const llm = models.filter((m) => (m.type || "llm") === "llm"); + groups.push({ alias, models: llm.length ? llm : models }); + } + return groups; +} + +// CLI source formats that real clients emit (the "specials" the user cares about) +export const CLI_SOURCE_FORMATS = [ + FORMATS.CLAUDE, + FORMATS.OPENAI_RESPONSES, + FORMATS.GEMINI, + FORMATS.OPENAI, + FORMATS.ANTIGRAVITY, +]; diff --git a/tests/translator/real/smoke-providers.real.test.js b/tests/translator/real/smoke-providers.real.test.js new file mode 100644 index 00000000000..576f8fd0067 --- /dev/null +++ b/tests/translator/real/smoke-providers.real.test.js @@ -0,0 +1,123 @@ +// REAL integration smoke test: sends a tiny prompt to EVERY provider that has an +// active credential in the local DB, through the full production path (handleChatCore). +// Gated by RUN_REAL=1 so the default `vitest run` never touches the network. +// +// RUN_REAL=1 npx vitest run "tests/translator/real/" +// +// Each provider becomes its own test; providers without an llm model or without an +// active credential are skipped automatically. +import { describe, it, expect, beforeAll } from "vitest"; +import { getProviderConnections } from "../../../src/lib/localDb.js"; +import { getProviderCredentials } from "../../../src/sse/services/auth.js"; +import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js"; +import { handleChatCore } from "../../../open-sse/handlers/chatCore.js"; +import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js"; + +const RUN_REAL = process.env.RUN_REAL === "1"; +const MAX_TOKENS = 32; +const TIMEOUT_MS = 90000; +// Optional comma-separated filter: REAL_PROVIDERS=kiro,codex,antigravity +const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "") + .split(",").map((s) => s.trim()).filter(Boolean); + +// Pick the first plain llm model for a provider (skip image/tts/embedding/etc). +function firstLlmModel(providerId) { + const models = getModelsByProviderId(providerId); + const llm = models.find((m) => (m.type || "llm") === "llm"); + return llm?.id || null; +} + +// Drain the full Web Response SSE body into raw text. +async function drainSSE(response) { + if (!response?.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + return out; +} + +let providerIds = []; + +beforeAll(async () => { + if (!RUN_REAL) return; + providerIds = targetProviders(); +}); + +describe.skipIf(!RUN_REAL).concurrent("REAL provider smoke", () => { + it("has active providers in DB", () => { + expect(providerIds.length).toBeGreaterThan(0); + }); + + // One concurrent test per provider; resolved lazily inside the test. + for (const providerId of (RUN_REAL ? targetProviders() : [])) { + it.concurrent( + `${providerId}: responds to a short prompt`, + async () => { + const model = firstLlmModel(providerId); + if (!model) return expect(true).toBe(true); // no llm model → skip silently + + const credentials = await getProviderCredentials(providerId, new Set(), model); + if (!credentials || credentials.allRateLimited) { + console.warn(`[skip] ${providerId}: no usable credential`); + return expect(true).toBe(true); + } + + const refreshed = await checkAndRefreshToken(providerId, credentials); + const result = await handleChatCore({ + body: { + model: `${providerId}/${model}`, + stream: true, + max_tokens: MAX_TOKENS, + messages: [{ role: "user", content: "Reply with the single word: hi" }], + }, + modelInfo: { provider: providerId, model }, + credentials: refreshed, + connectionId: credentials.connectionId, + }); + + if (!result.success) { + // Account/quota/auth problems are credential issues, not translation bugs → skip. + const credIssue = [401, 402, 403, 429].includes(Number(result.status)); + if (credIssue) { + console.warn(`[skip] ${providerId}: ${result.status} (credential/quota)`); + return expect(true).toBe(true); + } + throw new Error(`${providerId} failed: ${result.status} ${result.error}`); + } + + const raw = await drainSSE(result.response); + // Minimal sanity: got SSE data and a terminal signal or content. + expect(raw.length, `${providerId}: empty response`).toBeGreaterThan(0); + expect(/data:|finish_reason|"delta"|"content"|event:/.test(raw), `${providerId}: not SSE`).toBe(true); + }, + TIMEOUT_MS + ); + } +}); + +// Read the DB file directly (sync) at module-eval time so vitest can generate one +// test per provider before beforeAll runs. Applies REAL_PROVIDERS filter. +// Tolerates any failure (returns []). +function targetProviders() { + try { + const Database = require("better-sqlite3"); + const os = require("os"); + const path = require("path"); + const dbPath = process.env.DATA_DIR + ? path.join(process.env.DATA_DIR, "db", "data.sqlite") + : path.join(os.homedir(), ".9router", "db", "data.sqlite"); + const db = new Database(dbPath, { readonly: true }); + const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all(); + db.close(); + let list = rows.map((r) => r.provider).sort(); + if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p)); + return list; + } catch { + return []; + } +} diff --git a/tests/translator/registerAll.js b/tests/translator/registerAll.js new file mode 100644 index 00000000000..757934374f5 --- /dev/null +++ b/tests/translator/registerAll.js @@ -0,0 +1,22 @@ +// Eagerly import every translator so register() side-effects run under ESM/vitest. +// translator/index.js uses require() (bundler-only) which no-ops in vitest → import directly. +import "../../open-sse/translator/request/claude-to-openai.js"; +import "../../open-sse/translator/request/openai-to-claude.js"; +import "../../open-sse/translator/request/gemini-to-openai.js"; +import "../../open-sse/translator/request/openai-to-gemini.js"; +import "../../open-sse/translator/request/openai-to-vertex.js"; +import "../../open-sse/translator/request/antigravity-to-openai.js"; +import "../../open-sse/translator/request/openai-responses.js"; +import "../../open-sse/translator/request/openai-to-kiro.js"; +import "../../open-sse/translator/request/openai-to-cursor.js"; +import "../../open-sse/translator/request/openai-to-ollama.js"; +import "../../open-sse/translator/request/openai-to-commandcode.js"; +import "../../open-sse/translator/response/claude-to-openai.js"; +import "../../open-sse/translator/response/openai-to-claude.js"; +import "../../open-sse/translator/response/gemini-to-openai.js"; +import "../../open-sse/translator/response/openai-to-antigravity.js"; +import "../../open-sse/translator/response/openai-responses.js"; +import "../../open-sse/translator/response/kiro-to-openai.js"; +import "../../open-sse/translator/response/cursor-to-openai.js"; +import "../../open-sse/translator/response/ollama-to-openai.js"; +import "../../open-sse/translator/response/commandcode-to-openai.js"; diff --git a/tests/unit/antigravity-mitm.test.js b/tests/unit/antigravity-mitm.test.js new file mode 100644 index 00000000000..defed97b8bd --- /dev/null +++ b/tests/unit/antigravity-mitm.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "module"; +import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js"; + +// config.js is the CJS MITM bundle module (dependency-isolated for the runtime copy). +const require = createRequire(import.meta.url); +const { MODEL_NO_MAP } = require("../../src/mitm/config.js"); + +// All assertions below are grounded in a live MITM dump capture of Antigravity's +// streamGenerateContent requests (see AI_JOURNAL): the agent loop sends +// `gemini-3.5-flash-low`, tab-autocomplete sends `tab_jump_flash_lite_preview` / +// `tab_flash_lite_preview`. +describe("Antigravity MITM model handling", () => { + const ag = MITM_TOOLS.antigravity; + + it("flags the out-of-box agent/Default model mandatory", () => { + expect(ag.defaultModels.find((m) => m.id === "gemini-3.5-flash-low")?.mandatory).toBe(true); + }); + + it("leaves models not proven auto-sent optional", () => { + for (const id of ["gemini-3-flash-agent", "gemini-3.1-pro-low", "claude-sonnet-4-6", "gpt-oss-120b-medium"]) { + expect(ag.defaultModels.find((m) => m.id === id)?.mandatory).toBeFalsy(); + } + }); + + // Tab-autocomplete is latency-critical inline completion — it must passthrough natively, + // never get re-routed onto a chat-model mapping by the broad `flash` pattern. + it.each(["tab_jump_flash_lite_preview", "tab_flash_lite_preview"])( + "excludes tab-autocomplete model '%s' from re-routing", + (id) => { + expect((MODEL_NO_MAP.antigravity || []).some((re) => re.test(id))).toBe(true); + } + ); + + it("does not exclude real agent models from re-routing", () => { + for (const id of ["gemini-3.5-flash-low", "gemini-3-flash-agent", "claude-sonnet-4-6"]) { + expect((MODEL_NO_MAP.antigravity || []).some((re) => re.test(id))).toBe(false); + } + }); +}); diff --git a/tests/unit/claude-cloaking.test.js b/tests/unit/claude-cloaking.test.js new file mode 100644 index 00000000000..6b6e5dc67aa --- /dev/null +++ b/tests/unit/claude-cloaking.test.js @@ -0,0 +1,76 @@ +/** + * Unit tests for open-sse/utils/claudeCloaking.js + * + * Tests cover: + * - cloakClaudeTools() - tool renaming and forced tool_choice suffixing + */ + +import { describe, it, expect } from "vitest"; +import { cloakClaudeTools } from "../../open-sse/utils/claudeCloaking.js"; +import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js"; + +describe("cloakClaudeTools", () => { + const baseBody = { + tools: [{ name: "todo_write", description: "write todos", input_schema: { type: "object", properties: {} } }], + messages: [{ role: "user", content: [{ type: "text", text: "add a todo" }] }] + }; + + it("suffixes client tool names and maps them back", () => { + const { body, toolNameMap } = cloakClaudeTools(baseBody); + const suffixed = `todo_write${CLAUDE_TOOL_SUFFIX}`; + expect(body.tools.find(t => t.name === suffixed)).toBeDefined(); + expect(toolNameMap.get(suffixed)).toBe("todo_write"); + }); + + it("suffixes a forced tool_choice to match the renamed tool", () => { + const { body } = cloakClaudeTools({ + ...baseBody, + tool_choice: { type: "tool", name: "todo_write" } + }); + // Without this, Claude rejects: "Tool 'todo_write' not found in provided tools". + expect(body.tool_choice).toEqual({ type: "tool", name: `todo_write${CLAUDE_TOOL_SUFFIX}` }); + }); + + it("suffixes only the chosen tool when several are present", () => { + const { body } = cloakClaudeTools({ + tools: [ + { name: "search", input_schema: { type: "object", properties: {} } }, + { name: "todo_write", input_schema: { type: "object", properties: {} } } + ], + tool_choice: { type: "tool", name: "todo_write" } + }); + expect(body.tool_choice).toEqual({ type: "tool", name: `todo_write${CLAUDE_TOOL_SUFFIX}` }); + }); + + it("leaves non-forced tool_choice untouched", () => { + const auto = cloakClaudeTools({ ...baseBody, tool_choice: { type: "auto" } }); + expect(auto.body.tool_choice).toEqual({ type: "auto" }); + + const none = cloakClaudeTools({ ...baseBody }); + expect(none.body.tool_choice).toBeUndefined(); + }); + + it("does not suffix a forced choice that targets a non-client (decoy/built-in) tool", () => { + // "Bash" is an injected decoy sent unsuffixed; forcing it must stay as-is. + const { body } = cloakClaudeTools({ ...baseBody, tool_choice: { type: "tool", name: "Bash" } }); + expect(body.tool_choice).toEqual({ type: "tool", name: "Bash" }); + }); + + it("renames tool_use names in message history", () => { + const { body } = cloakClaudeTools({ + ...baseBody, + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "todo_write", input: {} }] } + ] + }); + const block = body.messages[0].content[0]; + expect(block.name).toBe(`todo_write${CLAUDE_TOOL_SUFFIX}`); + }); + + it("returns the body unchanged when there are no tools", () => { + const input = { messages: [{ role: "user", content: "hi" }], tool_choice: { type: "tool", name: "x" } }; + const { body, toolNameMap } = cloakClaudeTools(input); + expect(body).toBe(input); + expect(toolNameMap).toBeNull(); + }); +}); diff --git a/tests/unit/codex-refresh-token.test.js b/tests/unit/codex-refresh-token.test.js index 838c5fbf611..3b702af8088 100644 --- a/tests/unit/codex-refresh-token.test.js +++ b/tests/unit/codex-refresh-token.test.js @@ -14,21 +14,30 @@ const originalFetch = global.fetch; describe("Codex Refresh Token", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); + global.fetch = originalFetch; }); afterEach(() => { global.fetch = originalFetch; }); + function mockFetchWithJson(payload) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + global.fetch = fetchMock; + return fetchMock; + } + describe("refreshCodexToken", () => { it("should return new refresh_token when server provides one (token rotation)", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ + const fetchMock = mockFetchWithJson({ access_token: "new-access", refresh_token: "rotated-refresh-token", + id_token: "new-id-token", expires_in: 3600, - }), }); const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js"); @@ -36,21 +45,101 @@ describe("Codex Refresh Token", () => { expect(result.refreshToken).toBe("rotated-refresh-token"); expect(result.accessToken).toBe("new-access"); + expect(result.idToken).toBe("new-id-token"); + expect(fetchMock).toHaveBeenCalledWith( + "https://auth.openai.com/oauth/token", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + grant_type: "refresh_token", + refresh_token: "old-refresh-token", + }), + }) + ); }); it("should keep old refresh_token when server does not return new one", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ + mockFetchWithJson({ access_token: "new-access", expires_in: 3600, - }), }); const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js"); - const result = await refreshCodexToken("old-refresh-token", null); + const result = await refreshCodexToken("old-refresh-token-without-rotation", null); + + expect(result.refreshToken).toBe("old-refresh-token-without-rotation"); + }); + }); + + describe("CodexExecutor credential lifecycle", () => { + it("should refresh Codex credentials and preserve omitted id_token", async () => { + mockFetchWithJson({ + access_token: "new-access", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + + const { CodexExecutor } = await import("../../open-sse/executors/codex.js"); + const executor = new CodexExecutor(); + const result = await executor.refreshCredentials({ + connectionId: "codex-1", + refreshToken: "old-refresh-token", + idToken: "old-id-token", + }, null); + + expect(result.accessToken).toBe("new-access"); + expect(result.refreshToken).toBe("rotated-refresh-token"); + expect(result.idToken).toBe("old-id-token"); + expect(result.lastRefreshAt).toBeTruthy(); + expect(result.expiresAt).toBeTruthy(); + }); + + it("should refresh Codex when lastRefreshAt is older than the upstream stale window", async () => { + const { CodexExecutor } = await import("../../open-sse/executors/codex.js"); + const executor = new CodexExecutor(); + const farFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const staleRefresh = new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(); + const recentRefresh = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + + expect(executor.needsRefresh({ + refreshToken: "refresh-token", + expiresAt: farFuture, + lastRefreshAt: staleRefresh, + })).toBe(true); + + expect(executor.needsRefresh({ + refreshToken: "refresh-token", + expiresAt: farFuture, + lastRefreshAt: recentRefresh, + })).toBe(false); + }); + + it("should de-duplicate concurrent refreshes for the same Codex connection", async () => { + const fetchMock = mockFetchWithJson({ + access_token: "new-access", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + + const { refreshProviderCredentials } = await import("../../open-sse/services/oauthCredentialManager.js"); + const credentials = { + connectionId: "codex-single-flight", + refreshToken: "old-refresh-token", + }; + + const [first, second] = await Promise.all([ + refreshProviderCredentials("codex", credentials, null), + refreshProviderCredentials("codex", credentials, null), + ]); - expect(result.refreshToken).toBe("old-refresh-token"); + expect(first.accessToken).toBe("new-access"); + expect(second.accessToken).toBe("new-access"); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/hf-model-routing.test.js b/tests/unit/hf-model-routing.test.js new file mode 100644 index 00000000000..55d95ef9c6b --- /dev/null +++ b/tests/unit/hf-model-routing.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import { parseModel } from "../../open-sse/services/model.js"; + +describe("HuggingFace model alias parsing", () => { + it("resolves hf alias to huggingface provider", () => { + expect(parseModel("hf/black-forest-labs/FLUX.1-schnell")).toMatchObject({ + provider: "huggingface", + model: "black-forest-labs/FLUX.1-schnell", + providerAlias: "hf", + }); + }); +}); diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index 4aca1de1e0f..d74fea97c33 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -319,7 +319,7 @@ describe("handleImageGenerationCore", () => { headers: expect.objectContaining({ authorization: "Bearer codex-token", "chatgpt-account-id": "account-123", - version: "0.129.0", + version: "0.136.0", }), }) ); diff --git a/tests/unit/json-keepalive.test.js b/tests/unit/json-keepalive.test.js new file mode 100644 index 00000000000..32fcc146e7a --- /dev/null +++ b/tests/unit/json-keepalive.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +const { createJsonKeepaliveResponse } = await import("../../open-sse/utils/jsonKeepalive.js"); + +async function readText(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(decoder.decode(value)); + } + return chunks.join(""); +} + +describe("JSON keepalive", () => { + it("preserves immediate response status before keepalive starts", async () => { + const result = await createJsonKeepaliveResponse(Promise.resolve({ + success: false, + response: new Response(JSON.stringify({ error: { message: "bad" } }), { + status: 502, + headers: { "Content-Type": "application/json" } + }) + }), { intervalMs: 50 }); + + expect(result.response.status).toBe(502); + expect(await result.response.json()).toEqual({ error: { message: "bad" } }); + }); + + it("emits valid leading whitespace before final JSON", async () => { + const resultPromise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + success: true, + response: new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" } + }) + }); + }, 30); + }); + + const { response } = await createJsonKeepaliveResponse(resultPromise, { intervalMs: 5 }); + const text = await readText(response); + + expect(text.startsWith(" \n")).toBe(true); + expect(JSON.parse(text)).toEqual({ ok: true }); + }); +}); diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js new file mode 100644 index 00000000000..3eb9c7b8477 --- /dev/null +++ b/tests/unit/kiro-model-slots.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js"; + +// Guards the fix in commit 356607c: Kiro's agent/"vibe" mode sends modelId +// "auto" for the main turn and "simple-task" for background sub-tasks. Both +// need a mappable defaultModels slot — otherwise getMappedModel (src/mitm/server.js) +// returns null and the /generateAssistantResponse call is passed through to AWS +// instead of being routed to the user's chosen provider (surfacing as Kiro's +// "monthly usage limit" once the AWS quota is gone). +describe("Kiro MITM model slots", () => { + const kiro = MITM_TOOLS.kiro; + + it("exposes the kiro mitm tool", () => { + expect(kiro).toBeTruthy(); + expect(kiro.configType).toBe("mitm"); + expect(Array.isArray(kiro.defaultModels)).toBe(true); + }); + + it("offers a mappable slot for the agent default model id 'auto'", () => { + const auto = kiro.defaultModels.find((m) => m.id === "auto"); + expect(auto).toBeTruthy(); + expect(auto.alias).toBe("auto"); + }); + + it("offers a mappable slot for the background sub-task model id 'simple-task'", () => { + const simpleTask = kiro.defaultModels.find((m) => m.id === "simple-task"); + expect(simpleTask).toBeTruthy(); + expect(simpleTask.alias).toBe("simple-task"); + }); +}); diff --git a/tests/unit/minimax-usage.test.js b/tests/unit/minimax-usage.test.js index d2772da95cf..d903473d043 100644 --- a/tests/unit/minimax-usage.test.js +++ b/tests/unit/minimax-usage.test.js @@ -113,4 +113,127 @@ describe("MiniMax usage", () => { expect(usage.quotas["Music 2.6 (5h)"].used).toBe(5); expect(usage.quotas["Image 01 (5h)"].used).toBe(2); }); + + it("includes M-series percent-only buckets that have no count totals", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_remaining_percent: 70, + current_weekly_remaining_percent: 64, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 30, + total: 100, + remaining: 70, + remainingPercentage: 70, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 36, + total: 100, + remaining: 64, + remainingPercentage: 64, + }); + }); + + it("normalizes M-series percent-only buckets on the coding_plan (countMeansRemaining) endpoint too", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_remaining_percent: 80, + current_weekly_remaining_percent: 95, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax-cn", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 20, + total: 100, + remaining: 80, + remainingPercentage: 80, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 5, + total: 100, + remaining: 95, + remainingPercentage: 95, + }); + }); + + it("renders the M3-era MiniMax-M* wildcard as a friendly series label", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + modelName: "MiniMax-M*", + currentIntervalTotalCount: 4000, + currentIntervalUsageCount: 3200, + currentWeeklyTotalCount: 24000, + currentWeeklyUsageCount: 18000, + remainsTime: 1000, + weeklyRemainsTime: 2000, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax-cn", + apiKey: "test-key", + }); + + expect(usage.message).toBeUndefined(); + expect(Object.keys(usage.quotas)).toEqual([ + "M-series (5h)", + "M-series (7d)", + ]); + expect(usage.quotas["M-series (5h)"]).toMatchObject({ + used: 800, + total: 4000, + remaining: 3200, + }); + expect(usage.quotas["M-series (7d)"]).toMatchObject({ + used: 6000, + total: 24000, + remaining: 18000, + }); + }); + + it("prefers the upstream-provided remaining percent when counts are also present", async () => { + proxyAwareFetch.mockResolvedValueOnce( + usageResponse([ + { + model_name: "general", + current_interval_total_count: 4000, + current_interval_usage_count: 100, + current_interval_remaining_percent: 84, + current_weekly_total_count: 24000, + current_weekly_usage_count: 500, + current_weekly_remaining_percent: 42, + }, + ]) + ); + + const usage = await getUsageForProvider({ + provider: "minimax", + apiKey: "test-key", + }); + + expect(usage.quotas["M-series (5h)"].remainingPercentage).toBe(84); + expect(usage.quotas["M-series (7d)"].remainingPercentage).toBe(42); + }); }); diff --git a/tests/unit/model-test-routing.test.js b/tests/unit/model-test-routing.test.js new file mode 100644 index 00000000000..3eaa57239b0 --- /dev/null +++ b/tests/unit/model-test-routing.test.js @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getApiKeys: vi.fn(), + getConsistentMachineId: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getApiKeys: mocks.getApiKeys, +})); + +vi.mock("@/shared/utils/machineId", () => ({ + getConsistentMachineId: mocks.getConsistentMachineId, +})); + +vi.mock("next/server", () => ({ + NextResponse: { + json(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status || 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, +})); + +const originalFetch = global.fetch; + +describe("model test route kind routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getApiKeys.mockResolvedValue([{ key: "sk-internal", isActive: true }]); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + created: 1, + data: [{ b64_json: "abc" }], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("routes image model tests to /api/v1/images/generations", async () => { + const { POST } = await import("../../src/app/api/models/test/route.js"); + + const req = new Request("http://localhost/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "hf/black-forest-labs/FLUX.1-schnell", + kind: "image", + }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(body.ok).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/images/generations"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "hf/black-forest-labs/FLUX.1-schnell", + prompt: "test", + }), + }) + ); + }); + + it("routes embedding model tests to /api/v1/embeddings", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + data: [{ embedding: [0.1, 0.2] }], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + + const { POST } = await import("../../src/app/api/models/test/route.js"); + + const req = new Request("http://localhost/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "voyage/voyage-3-large", + kind: "embedding", + }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(body.ok).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/embeddings"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "voyage/voyage-3-large", + input: "test", + }), + }) + ); + }); + + it("fails embedding model tests when provider returns no embedding data", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + data: [{ embedding: null }], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + + const { POST } = await import("../../src/app/api/models/test/route.js"); + + const req = new Request("http://localhost/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "voyage/voyage-3-large", + kind: "embedding", + }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(body.ok).toBe(false); + expect(body.error).toBe("Provider returned no embedding data"); + }); + + it("routes stt model tests to /api/v1/audio/transcriptions", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + text: "test", + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + + const { POST } = await import("../../src/app/api/models/test/route.js"); + + const req = new Request("http://localhost/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "hf/openai/whisper-small", + kind: "stt", + }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(body.ok).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/audio/transcriptions"), + expect.objectContaining({ + method: "POST", + body: expect.any(FormData), + }) + ); + }); + + it("returns formatted HTTP errors for non-2xx embedding responses", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + error: { message: "bad upstream" }, + }), { + status: 502, + headers: { "Content-Type": "application/json" }, + })); + + const { POST } = await import("../../src/app/api/models/test/route.js"); + + const req = new Request("http://localhost/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "voyage/voyage-3-large", + kind: "embedding", + }), + }); + + const res = await POST(req); + const body = await res.json(); + + expect(body.ok).toBe(false); + expect(body.status).toBe(502); + expect(body.error).toBe("HTTP 502: bad upstream"); + }); +}); diff --git a/tests/unit/openai-responses-terminal-event.test.js b/tests/unit/openai-responses-terminal-event.test.js new file mode 100644 index 00000000000..db2e3ad8952 --- /dev/null +++ b/tests/unit/openai-responses-terminal-event.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js"; + +async function runTransform(input) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(input)); + controller.close(); + }, + }); + + const output = stream.pipeThrough( + createSSETransformStreamWithLogger( + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI_RESPONSES, + "codex", + null, + null, + "gpt-5.5", + ), + ); + + const reader = output.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; +} + +describe("OpenAI Responses streaming termination", () => { + it("emits a response.failed event when a Responses stream closes before a terminal event", async () => { + const output = await runTransform([ + `event: response.created`, + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_test", status: "in_progress" } })}`, + "", + `event: response.output_text.delta`, + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "partial" })}`, + "", + ].join("\n")); + + expect(output).toContain("event: response.failed"); + expect(output).toContain('"type":"response.failed"'); + expect(output).not.toContain("data: null"); + expect(output).toContain("data: [DONE]"); + }); + + it("does not add response.failed when a Responses stream already completed", async () => { + const output = await runTransform([ + `event: response.completed`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_test", status: "completed" } })}`, + "", + ].join("\n")); + + expect(output).toContain("event: response.completed"); + expect(output).not.toContain("event: response.failed"); + expect(output).not.toContain("data: null"); + expect(output).toContain("data: [DONE]"); + }); + + it("emits response.failed before DONE when a Responses stream sends DONE without a terminal event", async () => { + const output = await runTransform([ + `event: response.created`, + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_test", status: "in_progress" } })}`, + "", + "data: [DONE]", + "", + ].join("\n")); + + expect(output.indexOf("event: response.failed")).toBeLessThan(output.indexOf("data: [DONE]")); + expect(output.match(/data: \[DONE\]/g)).toHaveLength(1); + expect(output).not.toContain("data: null"); + }); +}); diff --git a/tests/unit/openai-to-claude.test.js b/tests/unit/openai-to-claude.test.js index 136f6d000c2..45b67fb236e 100644 --- a/tests/unit/openai-to-claude.test.js +++ b/tests/unit/openai-to-claude.test.js @@ -122,6 +122,50 @@ describe("openaiToClaudeRequest", () => { expect(systemText).toContain("You must respond with valid JSON"); }); }); + + describe("tool_choice handling", () => { + const baseBody = { + messages: [{ role: "user", content: "add a todo" }], + tools: [{ + type: "function", + function: { name: "todo_write", description: "write todos", parameters: { type: "object", properties: {} } } + }] + }; + + const choiceOf = (tc) => + openaiToClaudeRequest("claude-sonnet-4.5", { ...baseBody, tool_choice: tc }, false).tool_choice; + + it("converts OpenAI forced tool ({type:'function'}) to Claude {type:'tool'}", () => { + // Must NOT leak the OpenAI "function" type — Claude only accepts auto|any|tool|none. + expect(choiceOf({ type: "function", function: { name: "todo_write" } })) + .toEqual({ type: "tool", name: "todo_write" }); + }); + + it("maps string tool_choice values", () => { + expect(choiceOf("auto")).toEqual({ type: "auto" }); + expect(choiceOf("none")).toEqual({ type: "auto" }); + expect(choiceOf("required")).toEqual({ type: "any" }); + }); + + it("passes through Claude-native tool_choice objects unchanged", () => { + expect(choiceOf({ type: "tool", name: "todo_write" })).toEqual({ type: "tool", name: "todo_write" }); + expect(choiceOf({ type: "any" })).toEqual({ type: "any" }); + expect(choiceOf({ type: "none" })).toEqual({ type: "none" }); + }); + + it("never leaks an invalid type (falls back to auto)", () => { + // Malformed forced choice with no tool name, and unknown types, must not + // pass an invalid `type` through to Claude. + expect(choiceOf({ type: "function", function: {} })).toEqual({ type: "auto" }); + expect(choiceOf({ type: "function" })).toEqual({ type: "auto" }); + expect(choiceOf({ type: "bogus" })).toEqual({ type: "auto" }); + }); + + it("omits tool_choice entirely when the request has none", () => { + const result = openaiToClaudeRequest("claude-sonnet-4.5", baseBody, false); + expect(result.tool_choice).toBeUndefined(); + }); + }); }); describe("openaiToClaudeResponse", () => { diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index 3e82ccb97dd..3c2bc0258ca 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -143,4 +143,141 @@ describe("buildKiroPayload", () => { expect(currentMsg.userInputMessage.content).toContain("[Image: https://example.com/photo.jpg]"); }); }); + + describe("tool interaction without client-provided tools", () => { + // When the client omits `tools` (e.g. after compaction), structured tool + // content must be flattened to text so Kiro's "tools required" 400 never + // fires and no phantom tool-calling capability is advertised. + + it("should flatten OpenAI tool_calls + tool result into history text with no tools array", () => { + const body = { + messages: [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.txt"}' } } + ] + }, + { role: "tool", tool_call_id: "call_1", content: "file contents here" }, + { role: "user", content: "Summarize it" } + ] + // note: no `tools` + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + // No structured tool content anywhere + expect(cs.currentMessage.userInputMessage.userInputMessageContext).toBeUndefined(); + const allJson = JSON.stringify(cs); + expect(allJson).not.toContain("toolUses"); + expect(allJson).not.toContain("toolResults"); + + // Tool call + result preserved as readable text (call lands in history, + // result merges into the final currentMessage — assert across both) + expect(allJson).toContain("[Tool call: read_file("); + expect(allJson).toContain("[Tool result: file contents here]"); + }); + + it("should flatten Claude tool_use / tool_result blocks with no tools array", () => { + const body = { + messages: [ + { role: "user", content: "Do it" }, + { + role: "assistant", + content: [ + { type: "text", text: "Calling tool" }, + { type: "tool_use", id: "tu_1", name: "search", input: { q: "kiro" } } + ] + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu_1", content: "result text" } + ] + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + const allJson = JSON.stringify(cs); + expect(allJson).not.toContain("toolUses"); + expect(allJson).not.toContain("toolResults"); + expect(allJson).toContain("[Tool call: search("); + expect(allJson).toContain("[Tool result: result text]"); + }); + + it("should keep structured tools when the client DOES provide a tools array", () => { + const body = { + messages: [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.txt"}' } } + ] + }, + { role: "tool", tool_call_id: "call_1", content: "file contents here" }, + { role: "user", content: "Summarize it" } + ], + tools: [ + { + type: "function", + function: { name: "read_file", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + // Structured tool spec carried on currentMessage + const tools = cs.currentMessage.userInputMessage.userInputMessageContext?.tools; + expect(tools).toBeDefined(); + expect(tools[0].toolSpecification.name).toBe("read_file"); + + // Structured tool history preserved (not flattened to text) + const allJson = JSON.stringify(cs); + expect(allJson).toContain("toolUses"); + expect(allJson).not.toContain("[Tool call:"); + }); + + it("should salvage orphaned tool_result content as text instead of discarding it", () => { + // Client provides tools, but compaction removed the assistant tool_use + // message, leaving a tool_result whose tool_use_id matches nothing. + const body = { + messages: [ + { role: "user", content: "Start" }, + // (assistant tool_use for "orphan_call" was compacted away) + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "orphan_call", content: "important orphaned output" } + ] + }, + { role: "user", content: "Now continue" } + ], + tools: [ + { + type: "function", + function: { name: "some_tool", description: "x", parameters: { type: "object", properties: {}, required: [] } } + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + const allJson = JSON.stringify(cs); + + // The dangling structured reference is gone (would trigger Kiro 400)... + expect(allJson).not.toContain("orphan_call"); + // ...but the content is preserved as salvaged text, not discarded. + expect(allJson).toContain("[Tool result: important orphaned output]"); + }); + }); }); diff --git a/tests/unit/provider-models-minimax-m3.test.js b/tests/unit/provider-models-minimax-m3.test.js new file mode 100644 index 00000000000..8ad55d1b912 --- /dev/null +++ b/tests/unit/provider-models-minimax-m3.test.js @@ -0,0 +1,52 @@ +/** + * Unit tests verifying MiniMax-M3 is registered as a first-class + * built-in model for both the `minimax` (international) and + * `minimax-cn` (China) providers, with `targetFormat: "claude"`. + * + * Run: cd tests && NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest run tests/unit/provider-models-minimax-m3.test.js --reporter=verbose + */ + +import { describe, it, expect } from "vitest"; +import { PROVIDER_MODELS, getModelsByProviderId } from "../../open-sse/config/providerModels.js"; + +describe("MiniMax-M3 model registration", () => { + it("includes MiniMax-M3 in PROVIDER_MODELS.minimax", () => { + const models = PROVIDER_MODELS.minimax || []; + const m3 = models.find((m) => m.id === "MiniMax-M3"); + expect(m3).toBeDefined(); + expect(m3).toMatchObject({ + id: "MiniMax-M3", + name: "MiniMax M3", + targetFormat: "claude", + }); + }); + + it("includes MiniMax-M3 in PROVIDER_MODELS['minimax-cn']", () => { + const models = PROVIDER_MODELS["minimax-cn"] || []; + const m3 = models.find((m) => m.id === "MiniMax-M3"); + expect(m3).toBeDefined(); + expect(m3).toMatchObject({ + id: "MiniMax-M3", + name: "MiniMax M3", + targetFormat: "claude", + }); + }); + + it("exposes MiniMax-M3 through getModelsByProviderId for both provider IDs", () => { + const intlModels = getModelsByProviderId("minimax"); + const cnModels = getModelsByProviderId("minimax-cn"); + + expect(intlModels.some((m) => m.id === "MiniMax-M3")).toBe(true); + expect(cnModels.some((m) => m.id === "MiniMax-M3")).toBe(true); + }); + + it("does not regress the existing M2.7 / M2.5 / M2.1 entries", () => { + const intlIds = (PROVIDER_MODELS.minimax || []).map((m) => m.id); + const cnIds = (PROVIDER_MODELS["minimax-cn"] || []).map((m) => m.id); + + for (const id of ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"]) { + expect(intlIds).toContain(id); + expect(cnIds).toContain(id); + } + }); +}); diff --git a/tests/unit/provider-pricing-minimax-m3.test.js b/tests/unit/provider-pricing-minimax-m3.test.js new file mode 100644 index 00000000000..d73bd608f0f --- /dev/null +++ b/tests/unit/provider-pricing-minimax-m3.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { MODEL_PRICING } from "../../src/shared/constants/pricing.js"; + +describe("MiniMax-M3 pricing", () => { + it("includes MiniMax-M3 in MODEL_PRICING", () => { + expect(MODEL_PRICING["MiniMax-M3"]).toBeDefined(); + }); + + it("MiniMax-M3 pricing has numeric shape (input, output, cached)", () => { + const pricing = MODEL_PRICING["MiniMax-M3"]; + expect(pricing).toMatchObject({ + input: expect.any(Number), + output: expect.any(Number), + cached: expect.any(Number), + }); + }); + + it("MiniMax-M3 input price matches the design spec (0.30)", () => { + expect(MODEL_PRICING["MiniMax-M3"].input).toBe(0.30); + }); + + it("MiniMax-M3 output price matches the design spec (1.20)", () => { + expect(MODEL_PRICING["MiniMax-M3"].output).toBe(1.20); + }); + + it("MiniMax-M3 cached price matches the design spec (0.06)", () => { + expect(MODEL_PRICING["MiniMax-M3"].cached).toBe(0.06); + }); +}); \ No newline at end of file diff --git a/tests/unit/provider-test-models-routing.test.js b/tests/unit/provider-test-models-routing.test.js new file mode 100644 index 00000000000..00f3a7353f8 --- /dev/null +++ b/tests/unit/provider-test-models-routing.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnectionById: vi.fn(), + getApiKeys: vi.fn(), + getConsistentMachineId: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.getProviderConnectionById, + getApiKeys: mocks.getApiKeys, +})); + +vi.mock("@/shared/utils/machineId", () => ({ + getConsistentMachineId: mocks.getConsistentMachineId, +})); + +vi.mock("next/server", () => ({ + NextResponse: { + json(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status || 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, +})); + +const originalFetch = global.fetch; + +describe("provider test-models route kind routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getProviderConnectionById.mockResolvedValue({ + id: "conn-hf", + provider: "huggingface", + }); + mocks.getApiKeys.mockResolvedValue([{ key: "sk-internal", isActive: true }]); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + global.fetch = vi.fn((url) => { + if (String(url).includes("/api/v1/images/generations")) { + return Promise.resolve(new Response(JSON.stringify({ + created: 1, + data: [{ b64_json: "abc" }], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + } + return Promise.resolve(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("routes huggingface image models to /api/v1/images/generations", async () => { + const { POST } = await import("../../src/app/api/providers/[id]/test-models/route.js"); + + const req = new Request("http://localhost/api/providers/conn-hf/test-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + const res = await POST(req, { params: Promise.resolve({ id: "conn-hf" }) }); + const body = await res.json(); + + expect(body.provider).toBe("huggingface"); + expect(body.results.some((r) => r.modelId === "black-forest-labs/FLUX.1-schnell" && r.ok)).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/images/generations"), + expect.objectContaining({ + method: "POST", + }) + ); + }); +}); diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js index fc12be20a55..fc5f20a1af2 100644 --- a/tests/unit/qoder.test.js +++ b/tests/unit/qoder.test.js @@ -15,7 +15,12 @@ import crypto from "crypto"; import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js"; import { buildCosyHeaders } from "../../src/lib/qoder/cosy.js"; import { QoderService } from "../../src/lib/oauth/services/qoder.js"; -import { QODER_CHAT_URL_ENCODED, QODER_MODEL_LIST_URL } from "../../src/lib/qoder/constants.js"; +import { + QODER_CHAT_URL_ENCODED, + QODER_MODEL_LIST_URL, + QODER_MODEL_MAP, +} from "../../src/lib/qoder/constants.js"; +import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js"; import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js"; // Convenience aliases — tests were originally written against module-level @@ -25,6 +30,16 @@ const generatePkcePair = () => new QoderService().generatePkcePair(); const initiateDeviceFlow = () => new QoderService().initiateDeviceFlow(); const parseExpiry = QoderService.parseExpiry; +describe("QODER_MODEL_MAP", () => { + it("allows Qoder's latest model key", () => { + expect(QODER_MODEL_MAP.qmodel_latest).toBe("qmodel_latest"); + }); + + it("exposes Qoder's latest model in the static provider catalog", () => { + expect(PROVIDER_MODELS.qd.some((model) => model.id === "qmodel_latest")).toBe(true); + }); +}); + describe("qoderEncodeBody", () => { it("preserves base64 length (input length divisible by 3)", () => { const input = Buffer.from("abcdef", "utf8"); // 6 bytes → 8 base64 chars diff --git a/tests/unit/reasoningContentInjector.test.js b/tests/unit/reasoningContentInjector.test.js index 56399ac596b..b0e360ca1d4 100644 --- a/tests/unit/reasoningContentInjector.test.js +++ b/tests/unit/reasoningContentInjector.test.js @@ -84,6 +84,69 @@ describe("injectReasoningContent — DeepSeek thinking round-trip", () => { }); }); +describe("injectReasoningContent — MiniMax thinking round-trip", () => { + const minimaxAssistantMsg = { role: "assistant", content: "here is a response", reasoning_content: "" }; + const minimaxAssistantWithToolCall = { + role: "assistant", + content: "", + tool_calls: [{ id: "call_x", type: "function", function: { name: "get_weather", arguments: "{}" } }], + reasoning_content: "", + }; + + it("injects reasoning_content on a minimax assistant message that lacks it", () => { + const out = injectReasoningContent({ + provider: "minimax", + model: "MiniMax-M2.7", + body: bodyWith([{ role: "user", content: "hi" }, minimaxAssistantMsg]), + }); + const assistant = out.messages.find((m) => m.role === "assistant"); + expect(typeof assistant.reasoning_content).toBe("string"); + expect(assistant.reasoning_content.length).toBeGreaterThan(0); + }); + + it("injects reasoning_content on minimax assistant message with tool_calls but no reasoning_content", () => { + const out = injectReasoningContent({ + provider: "minimax", + model: "MiniMax-M2.7", + body: bodyWith([{ role: "user", content: "hi" }, minimaxAssistantWithToolCall]), + }); + const assistant = out.messages.find((m) => m.role === "assistant"); + expect(typeof assistant.reasoning_content).toBe("string"); + expect(assistant.reasoning_content.length).toBeGreaterThan(0); + }); + + it("applies provider-level rule for provider 'minimax-cn' (scope all)", () => { + const out = injectReasoningContent({ + provider: "minimax-cn", + model: "MiniMax-M2.5", + body: bodyWith([{ role: "assistant", content: "answer" }]), + }); + expect(out.messages[0].reasoning_content).toBeDefined(); + }); + + it("preserves an existing reasoning_content on minimax instead of overwriting", () => { + const original = "MiniMax chain of thought reasoning"; + const out = injectReasoningContent({ + provider: "minimax", + model: "MiniMax-M2.7", + body: bodyWith([{ ...minimaxAssistantMsg, reasoning_content: original }]), + }); + expect(out.messages[0].reasoning_content).toBe(original); + }); + + it("DefaultExecutor transformRequest runs the injector for minimax", () => { + const { DefaultExecutor } = require("../../open-sse/executors/default.js"); + const executor = new DefaultExecutor("minimax"); + const out = executor.transformRequest( + "MiniMax-M2.7", + bodyWith([{ role: "user", content: "hi" }, minimaxAssistantMsg]), + ); + const assistant = out.messages.find((m) => m.role === "assistant"); + expect(typeof assistant.reasoning_content).toBe("string"); + expect(assistant.reasoning_content.length).toBeGreaterThan(0); + }); +}); + describe("OpenCodeExecutor — issue #1543 regression", () => { it("runs the injector so deepseek-v4-flash-free round-trips reasoning_content", () => { const executor = new OpenCodeExecutor(); diff --git a/tests/unit/responses-abort-terminal.test.js b/tests/unit/responses-abort-terminal.test.js new file mode 100644 index 00000000000..10af5848476 --- /dev/null +++ b/tests/unit/responses-abort-terminal.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { createDisconnectAwareStream } from "../../open-sse/utils/streamHandler.js"; +import { buildAbortedResponsesTerminalBytes } from "../../open-sse/utils/responsesStreamHelpers.js"; + +// Minimal stream controller stub +function makeController() { + let connected = true; + return { + signal: new AbortController().signal, + startTime: Date.now(), + isConnected: () => connected, + handleComplete: () => { connected = false; }, + handleError: () => { connected = false; }, + handleDisconnect: () => { connected = false; }, + abort: () => { connected = false; }, + }; +} + +async function readAll(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; +} + +describe("Responses abort terminal synthesis", () => { + it("emits response.failed + [DONE] when upstream errors (abort/stall)", async () => { + // Upstream readable that errors mid-stream (simulates fetch abort on stall) + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("event: response.created\ndata: {}\n\n")); + controller.error(new Error("stream stall timeout")); + }, + }); + + const out = createDisconnectAwareStream( + { readable: upstream, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, + makeController(), + buildAbortedResponsesTerminalBytes + ); + + const text = await readAll(out); + expect(text).toContain("event: response.failed"); + expect(text).toContain("data: [DONE]"); + }); + + it("does not synthesize terminal for non-Responses streams (callback null)", async () => { + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: hi\n\n")); + controller.error(new Error("socket hang up")); + }, + }); + + const out = createDisconnectAwareStream( + { readable: upstream, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, + makeController(), + null + ); + + const text = await readAll(out); + expect(text).not.toContain("response.failed"); + expect(text).not.toContain("[DONE]"); + }); +}); diff --git a/tests/unit/streaming-heartbeat-keepalive.test.js b/tests/unit/streaming-heartbeat-keepalive.test.js index 73a08a59ee6..4e1646293a1 100644 --- a/tests/unit/streaming-heartbeat-keepalive.test.js +++ b/tests/unit/streaming-heartbeat-keepalive.test.js @@ -13,7 +13,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Contract locked in here: // 1. While upstream is silent, the transform emits heartbeat comment lines. // 2. Comment lines are SSE comments (start with ":") so clients ignore them. -// 3. Once the first real chunk arrives, heartbeats stop (no interleaving). +// 3. Heartbeats resume during later idle gaps after real chunks. // 4. The heartbeat timer is cleared on termination (no leak past finalize). vi.mock("@/lib/usageDb.js", () => ({ @@ -69,6 +69,47 @@ function pipeWithDelay({ firstChunkDelayMs, chunks, heartbeatIntervalMs, onStrea })(); } +function pipeWithChunkDelays({ chunkDelaysMs, chunks, heartbeatIntervalMs, onStreamComplete }) { + const encoder = new TextEncoder(); + let index = 0; + const source = new ReadableStream({ + async pull(controller) { + if (index >= chunks.length) { + controller.close(); + return; + } + const delay = chunkDelaysMs[index] || 0; + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + controller.enqueue(encoder.encode(chunks[index++])); + }, + }); + + const transform = createSSEStream({ + mode: "passthrough", + provider: "claude", + model: "claude-opus-4-8", + connectionId: "conn-1234", + body: { messages: [{ role: "user", content: "hi" }] }, + onStreamComplete, + apiKey: "sk-test", + heartbeatIntervalMs, + }); + + const readable = source.pipeThrough(transform); + const reader = readable.getReader(); + const decoder = new TextDecoder(); + + return (async () => { + const received = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received.push(decoder.decode(value)); + } + return received.join(""); + })(); +} + describe("streaming idle keepalive", () => { beforeEach(() => vi.clearAllMocks()); @@ -114,6 +155,26 @@ describe("streaming idle keepalive", () => { expect(onStreamComplete).toHaveBeenCalledTimes(1); }); + it("emits SSE comment heartbeats during idle gaps after the first token", async () => { + const onStreamComplete = vi.fn(); + const output = await pipeWithChunkDelays({ + chunkDelaysMs: [0, HEARTBEAT_INTERVAL_MS * 3, 0], + chunks: [ + 'data: {"choices":[{"delta":{"content":"a"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"b"}}]}\n\n', + "data: [DONE]\n\n", + ], + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + onStreamComplete, + }); + + const heartbeatCount = (output.match(/: 9router-keepalive/g) || []).length; + expect(heartbeatCount).toBeGreaterThanOrEqual(1); + expect(output).toContain('"content":"a"'); + expect(output).toContain('"content":"b"'); + expect(onStreamComplete).toHaveBeenCalledTimes(1); + }); + it("heartbeat disabled when interval <= 0", async () => { const onStreamComplete = vi.fn(); const output = await pipeWithDelay({ diff --git a/tests/vitest.config.js b/tests/vitest.config.js index 0df209bf64e..d341b917b8f 100644 --- a/tests/vitest.config.js +++ b/tests/vitest.config.js @@ -9,15 +9,17 @@ export default defineConfig({ environment: "node", globals: true, include: ["**/*.test.js"], + // Allow many it.concurrent cases (real provider smoke runs ~50 providers in parallel) + maxConcurrency: 60, // Suppress noisy console output from handlers under test silent: false, }, resolve: { - alias: { - // Resolve open-sse/* imports to the actual local package - "open-sse": resolve(__dirname, "../open-sse"), - // Resolve @/* imports to src directory - "@": resolve(__dirname, "../src"), - }, + // Use array form so subpath aliases (e.g. "@/lib/db/index.js") resolve correctly. + alias: [ + { find: /^open-sse\//, replacement: resolve(__dirname, "../open-sse") + "/" }, + { find: "open-sse", replacement: resolve(__dirname, "../open-sse") }, + { find: /^@\//, replacement: resolve(__dirname, "../src") + "/" }, + ], }, });