feat: initial OmniRoute release (rebranded from 9router)

This project is inspired by and originally forked from 9router by decolua
(https://github.com/decolua/9router).

Full rebrand: 9router → OmniRoute across all source code, configuration,
Docker, documentation, and assets.
This commit is contained in:
diegosouzapw
2026-02-13 16:26:43 -03:00
commit 699329944e
430 changed files with 69016 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
---
description: Git workflow — NEVER commit directly to main. Always use feature branches.
---
# Git Workflow
## ⚠️ CRITICAL RULE: NEVER commit directly to `main`
## Steps
1. **Before starting any work**, create a feature branch from `main`:
```bash
git checkout main && git pull origin main
git checkout -b feature/<feature-name>
```
2. **During development**, commit to the feature branch:
```bash
git add -A && git commit -m "<type>(<scope>): <description>"
```
3. **Before pushing**, verify the build passes:
```bash
npm run build
```
4. **When the feature is complete and verified**, push the branch and STOP:
```bash
git push origin feature/<feature-name>
```
5. **DO NOT** create a PR, merge, or push to `main`. Let the user handle that.
## Branch naming convention
- `feature/<name>` — new features
- `fix/<name>` — bugfixes
- `refactor/<name>` — refactoring
- `docker/<name>` — Docker / infrastructure changes
- `style/<name>` — UI / CSS changes
## Commit types
- `feat` — new feature
- `fix` — bugfix
- `refactor` — code refactoring
- `style` — UI / CSS changes
- `docker` — Docker / infrastructure
- `docs` — documentation
- `chore` — maintenance

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# VCS
.git
**/.git
# Editor
.vscode
**/.vscode
# Dependencies and build output
node_modules
.next
out
build
dist
coverage
# Runtime data and logs
data
logs
# Local env files (inject at runtime via --env-file or -e)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

97
.env.example Normal file
View File

@@ -0,0 +1,97 @@
# OmniRoute environment contract
# This file reflects actual runtime usage in the current codebase.
# Required
JWT_SECRET=change-me-to-a-long-random-secret
INITIAL_PASSWORD=123456
DATA_DIR=/var/lib/omniroute
# Recommended runtime variables
PORT=20128
NODE_ENV=production
# Recommended security and ops variables
API_KEY_SECRET=endpoint-proxy-api-key-secret
MACHINE_ID_SALT=endpoint-proxy-salt
ENABLE_REQUEST_LOGS=false
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
# Cloud sync variables
# Must point to this running instance so internal sync jobs can call /api/sync/cloud.
# Server-side preferred variables:
BASE_URL=http://localhost:20128
CLOUD_URL=https://omniroute.com
# Backward-compatible/public variables:
NEXT_PUBLIC_BASE_URL=http://localhost:20128
NEXT_PUBLIC_CLOUD_URL=https://omniroute.com
# Optional outbound proxy variables for upstream provider calls
# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy
# Enable socks5 support only when rollout is approved
# ENABLE_SOCKS5_PROXY=false
# NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# Optional CLI runtime overrides (Docker/host integration)
# CLI_MODE=auto
# CLI_EXTRA_PATHS=/host-cli/bin
# CLI_CONFIG_HOME=/root
# CLI_ALLOW_CONFIG_WRITES=true
# CLI_CLAUDE_BIN=claude
# CLI_CODEX_BIN=codex
# CLI_DROID_BIN=droid
# CLI_OPENCLAW_BIN=openclaw
# CLI_CURSOR_BIN=agent
# CLI_CLINE_BIN=cline
# CLI_ROO_BIN=roo
# CLI_CONTINUE_BIN=cn
# Currently unused by application runtime (kept as reference)
# INSTANCE_NAME=omniroute
# Provider OAuth Credentials (optional — override hardcoded defaults)
# These can also be set via data/provider-credentials.json
# CLAUDE_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# GEMINI_CLI_OAUTH_CLIENT_ID=
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
# CODEX_OAUTH_CLIENT_ID=
# CODEX_OAUTH_CLIENT_SECRET=
# QWEN_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_ID=
# IFLOW_OAUTH_CLIENT_SECRET=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
# DEEPSEEK_API_KEY=
# GROQ_API_KEY=
# XAI_API_KEY=
# MISTRAL_API_KEY=
# PERPLEXITY_API_KEY=
# TOGETHER_API_KEY=
# FIREWORKS_API_KEY=
# CEREBRAS_API_KEY=
# COHERE_API_KEY=
# NVIDIA_API_KEY=
# Embedding Providers (optional — used by /v1/embeddings)
# NEBIUS_API_KEY=
# Provider keys above (openai, mistral, together, fireworks, nvidia) also work for embeddings
# Timeout settings
# FETCH_TIMEOUT_MS=120000
# STREAM_IDLE_TIMEOUT_MS=60000
# CORS configuration (default: * allows all origins)
# CORS_ORIGINS=*
# Logging
# LOG_LEVEL=info
# LOG_FORMAT=text

27
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "deps"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
development:
dependency-type: "development"
ignore:
- dependency-name: "react"
update-types: ["version-update:semver-major"]
- dependency-name: "react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "next"
update-types: ["version-update:semver-major"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

22
.github/workflows/codex-review.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Codex PR Review
on:
pull_request:
types: [opened, synchronize]
jobs:
request-codex-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Request Codex Review
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '@codex review'
});

83
.gitignore vendored Normal file
View File

@@ -0,0 +1,83 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
cloud/*
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.bin/*
data/
logs/*
source/*
.cursor/*
docs/*
!docs/ARCHITECTURE.md
!docs/CODEBASE_DOCUMENTATION.md
!docs/CONTRIBUTING.md
!docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
!docs/frontend-backend-provider-gap-report.md
!docs/adr/
!docs/cli-tools/
!docs/planning/
!docs/improvement-plans/
!docs/api/
test/*
bin/*
open-sse/test/*
RM.vn.md
RM.md
cursor/*
PUBLIC.md
scripts/*
Thanks.md
PUBLIC.en.md
PR/*
package-lock.json
#Ignore vscode AI rules
.github/instructions/codacy.instructions.md
README1.md
# Playwright
test-results/
playwright-report/
blob-report/

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

60
.npmignore Normal file
View File

@@ -0,0 +1,60 @@
# Database files - NEVER publish
data/
**/data/
**/db.json
# Development
src/
docs/
test/
agents/
scripts/
worker/
shared-sse/
copilot-api/
CLIProxyAPI/
# Config files
*.md
!README.md
.gitignore
.env*
jsconfig.json
eslint.config.mjs
postcss.config.mjs
next.config.mjs
tsconfig.json
# Build artifacts that shouldn't be published
.next/cache/
.next/standalone/data/

20
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"css.lint.unknownAtRules": "ignore",
"sonarlint.rules": {
"css:S4662": {
"level": "off"
},
"javascript:S6747": {
"level": "off"
},
"javascript:S7764": {
"level": "off"
},
"javascript:S6772": {
"level": "off"
},
"javascript:S3776": {
"level": "off"
}
}
}

105
AGENTS.md Normal file
View File

@@ -0,0 +1,105 @@
# omniroute — Agent Guidelines
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
(OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, etc.)
## Stack
- **Runtime**: Next.js 16 (App Router), Node.js, ES Modules
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
- **Streaming**: SSE via `open-sse` internal package
- **Styling**: Tailwind CSS v4
- **Docker**: Multi-stage Dockerfile, 3 profiles (base / cli / host)
## Architecture
### Data Layer (`src/lib/db/`)
All persistence uses SQLite through domain-specific modules:
| Module | Responsibility |
| -------------- | ------------------------------------------ |
| `core.js` | SQLite engine, migrations, WAL, encryption |
| `providers.js` | Provider connections & nodes |
| `models.js` | Model aliases, MITM aliases, custom models |
| `combos.js` | Combo configurations |
| `apiKeys.js` | API key management & validation |
| `settings.js` | Settings, pricing, proxy config |
| `backup.js` | Backup / restore operations |
`src/lib/localDb.js` is a **re-export layer only** — all 27+ consumers import from it,
but the real logic lives in `src/lib/db/`.
### Request Pipeline (`open-sse/`)
| Handler | Role |
| ----------------------- | ------------------------------------------- |
| `chatCore.js` | Main chat completions proxy (SSE / non-SSE) |
| `responsesHandler.js` | OpenAI Responses API compat |
| `responseTranslator.js` | Format translation for Responses API |
| `embeddings.js` | Embedding proxy |
| `imageGeneration.js` | Image generation proxy |
| `sseParser.js` | SSE stream parser |
| `usageExtractor.js` | Token usage extraction from responses |
Translation between provider formats: `open-sse/translator/`
### OAuth & Tokens (`src/lib/oauth/`)
18 modules handling OAuth flows, token refresh, and provider credentials.
Default credentials are hardcoded in `src/lib/oauth/constants/oauth.js`,
overridable via env vars or `data/provider-credentials.json`.
### Supporting Systems
| System | Location |
| -------------------------- | ------------------------------------------------- |
| Usage tracking & analytics | `src/lib/usageDb.js`, `src/lib/usageAnalytics.js` |
| Token health checks | `src/lib/tokenHealthCheck.js` |
| Cloud sync | `src/lib/cloudSync.js` |
| Proxy logging | `src/lib/proxyLogger.js` |
| Data paths resolution | `src/lib/dataPaths.js` |
### Adding a New Provider
1. Register in `src/shared/constants/providers.js`
2. Add executor in `open-sse/executors/`
3. Add translator rules in `open-sse/translator/` (if non-OpenAI format)
4. Add OAuth config in `src/lib/oauth/constants/oauth.js` (if OAuth-based)
## Review Focus
### Security
- No hardcoded API keys or secrets in commits
- Auth middleware on all API routes
- Input validation on user-facing endpoints
- SQLite encryption key must not be logged
### Architecture
- DB operations go through `src/lib/db/` modules, never raw SQL in routes
- Provider requests flow through `open-sse/handlers/`
- Translations use `open-sse/translator/` modules
- `localDb.js` is re-exports only — add new functions to the proper `db/*.js` module
### Code Quality
- Consistent error handling with try/catch
- Proper HTTP status codes
- No memory leaks in SSE streams (abort signals, cleanup)
- Rate limit headers must be parsed correctly
### Docker
- Dockerfile has two targets: `runner-base` and `runner-cli`
- `docker-compose.yml` — development (3 profiles)
- `docker-compose.prod.yml` — isolated production instance (port 20130)
- Data persists in named volumes (`omniroute-data` / `omniroute-prod-data`)
### Review Mode
- Provide analysis and suggestions only
- Focus on bugs, security, performance, and best practices

193
CHANGELOG.md Normal file
View File

@@ -0,0 +1,193 @@
# v0.2.75 (2026-02-11)
## Features
- Added API key attribution in usage and call logs:
- request pipeline now captures API key metadata (`id`/`name`) when available.
- analytics now includes API-key level aggregates (`summary.uniqueApiKeys`, `byApiKey`).
- Enhanced Usage dashboard with API key observability:
- added API key distribution donut + sortable/filterable table (cost/tokens/requests).
- added explicit API key filter in Request Logger and API key column in log entries (masked display).
- Added in-app documentation page at `/docs` with:
- quick start checklist and common use cases.
- endpoint reference (`/v1/*` + rewrite helper paths).
- client compatibility notes for Cherry Studio and Codex/Copilot model routing.
- Updated docs navigation links across landing/dashboard UI to use local in-app docs instead of placeholder links.
## Fixes
- Unified storage path policy for `localDb` and `usageDb`:
- both now follow `DATA_DIR` first.
- when `DATA_DIR` is unset on Linux/macOS, support `XDG_CONFIG_HOME/omniroute`.
- legacy `~/.omniroute` files are auto-migrated when the resolved directory changes.
- Added build-phase guard to `usageDb` (in-memory mode during `next build`) to avoid unintended disk writes.
- Added optional feature flag for compatible nodes:
- `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE=true` allows multiple connections per OpenAI/Anthropic-compatible node.
- Improved LAN/reverse-proxy cookie security detection in login route (`x-forwarded-proto` parsing + protocol fallback).
- Hardened Antigravity request normalization for Gemini 3 Flash by dropping empty `contents` after `thought` filtering and adding preview-model compatibility mapping.
- Hardened non-stream fallback parsing when upstream returns SSE unexpectedly:
- non-stream responses now detect SSE by header/content and parse accordingly.
- uses Responses SSE parsing for `openai-responses` targets to avoid `JSON.parse` crashes.
- fixes `stream=false` stability for Codex chat compatibility paths.
- Fixed CLI tool/runtime and OAuth refresh reliability:
- increased Cline runtime health-check timeout to avoid false `not runnable` status.
- added refresh support for `cline` and `kimi-coding` OAuth providers.
- health-check scheduler now skips providers without supported refresh flow instead of forcing error state.
- Improved provider health diagnostics and retest flow:
- `/api/providers/[id]/test` now returns structured diagnosis (`runtime_error`, `upstream_auth_error`, `token_refresh_failed`, etc).
- persisted diagnostic metadata in connections (`lastErrorType`, `lastErrorSource`, `errorCode`, `lastTested`).
- provider detail UI now includes explicit `Retest` action per connection and clearer status badges separating local runtime issues from upstream auth failures.
# v0.2.74 (2026-02-11)
## Fixes
- Fixed model resolution fallback for unprefixed models to avoid incorrect OpenAI routing:
- Resolve to unique non-OpenAI provider when unambiguous.
- Return explicit `400` for ambiguous unprefixed models with prefix guidance (`gh/<model>`, etc).
- Keep OpenAI fallback for unknown/unmapped models for backward compatibility.
- Added GitHub Copilot dynamic endpoint selection for Codex-family models:
- Codex models now route to `/responses`.
- Non-Codex models remain on `/chat/completions`.
- Added non-stream (`stream=false`) translation path for OpenAI Responses payloads to OpenAI Chat Completions response shape.
- Added non-stream usage extraction support for OpenAI Responses (`input_tokens`/`output_tokens`).
- Updated GitHub model catalog with upstream corrections and compatibility aliases:
- `raptor-mini``oswe-vscode-prime`
- `gemini-3-pro``gemini-3-pro-preview`
- `gemini-3-flash``gemini-3-flash-preview`
- Added `claude-opus-4.6`, `gpt-4o`, `gpt-4o-mini`, `gpt-4`, `gpt-3.5-turbo`.
# v0.2.73 (2026-02-09)
## Features
- Expanded provider registry from 18 → 28 providers:
- **Phase 1:** DeepSeek, Groq, xAI (Grok), Mistral, Perplexity — high-priority API Key providers.
- **Phase 4:** Together AI, Fireworks AI, Cerebras, Cohere, NVIDIA NIM — medium-priority API Key providers.
- All use `DefaultExecutor` with OpenAI-compatible format.
- Added `/v1/embeddings` endpoint with 6 providers and 9 embedding models:
- Nebius, OpenAI, Mistral, Together AI, Fireworks AI, NVIDIA NIM.
- New handler (`open-sse/handlers/embeddings.js`), registry (`open-sse/config/embeddingRegistry.js`), and Next.js route.
- Added `/v1/images/generations` endpoint with 4 providers and 9 image models:
- OpenAI (DALL-E), xAI (Grok Image), Together AI (FLUX), Fireworks AI.
- New handler (`open-sse/handlers/imageGeneration.js`), registry (`open-sse/config/imageRegistry.js`), and Next.js route.
- Added `<think>` tag parser (`open-sse/utils/thinkTagParser.js`) for reasoning models (DeepSeek, Qwen).
- Supports both full-text extraction and streaming delta processing.
- Enhanced `/v1/models` endpoint to list chat, embedding, and image models with type metadata.
- Updated `open-sse/index.js` with exports for new handlers, registries, and utilities.
## Frontend
- Added "Available Endpoints" card to the Endpoint page with collapsible sections for Chat Completions (127 models), Embeddings (9 models), and Image Generation (9 models), grouped by provider.
- Added Nebius AI to `providers.js` with icon, color, and text icon.
- Generated 11 provider PNG icons (128×128) from SVG for all new providers.
- Added auto-open of Add Connection modal when a provider detail page has zero connections.
- Updated Translator debug page with all 28 providers (was missing 12).
# v0.2.72 (2026-02-08)
## Features
- Split Kimi into dual providers: `kimi` (OpenAI-compatible) and `kimi-coding` (legacy Moonshot API), with separate model catalogs, icons, and routing (`f40ab34`).
- Added hybrid CLI runtime support with Docker profiles: `runner-base` (minimal) and `runner-cli` (bundled CLIs), host-mount mode, per-tool env overrides (`CLI_*_BIN`, `CLI_EXTRA_PATHS`), and generic `/api/cli-tools/runtime/[toolId]` endpoint (`871db97`).
- Hardened cloud sync/auth flow with SSE fallback for non-streaming calls, added security test scripts for Docker hardening, cloud endpoint compatibility, and end-to-end sync validation (`6c87ba3`).
# v0.2.66 (2026-02-06)
## Features
- Added Cursor provider end-to-end support, including OAuth import flow and translator/executor integration (`137f315`, `0a026c7`).
- Enhanced auth/settings flow with `requireLogin` control and `hasPassword` state handling in dashboard/login APIs (`249fc28`).
- Improved usage/quota UX with richer provider limit cards, new quota table, and clearer reset/countdown display (`32aefe5`).
- Added model support for custom providers in UI/combos/model selection (`a7a52be`).
- Expanded model/provider catalog:
- Codex updates: GPT-5.3 support, translation fixes, thinking levels (`127475d`)
- Added Claude Opus 4.6 model (`e8aa3e2`)
- Added MiniMax Coding (CN) provider (`7c609d7`)
- Added iFlow Kimi K2.5 model (`9e357a7`)
- Updated CLI tools with Droid/OpenClaw cards and base URL visibility improvements (`a2122e3`)
- Added auto-validation for provider API keys when saving settings (`b275dfd`).
- Added Docker/runtime deployment docs and architecture documentation updates (`5e4a15b`).
## Fixes
- Improved local-network compatibility by allowing auth cookie flow over HTTP deployments (`0a394d0`).
- Improved Antigravity quota/stream handling and Droid CLI compatibility behavior (`3c65e0c`, `c612741`, `8c6e3b8`).
- Fixed GitHub Copilot model mapping/selection issues (`95fd950`).
- Hardened local DB behavior with corrupt JSON recovery and schema-shape migration safeguards (`e6ef852`).
- Fixed logout/login edge cases:
- Prevent unintended auto-login after logout (`49df3dc`)
- Avoid infinite loading on failed `/api/settings` responses (`01c9410`)
# v0.2.56 (2026-02-04)
## Features
- Added Anthropic-compatible provider support across providers API/UI flow (`da5bdef`).
- Added provider icons to dashboard provider pages/lists (`60bd686`, `8ceb8f2`).
- Enhanced usage tracking pipeline across response handlers/streams with buffered accounting improvements (`a33924b`, `df0e1d6`, `7881db8`).
## Fixes
- Fixed usage conversion and related provider limits presentation issues (`e6e44ac`).
# v0.2.52 (2026-02-02)
## Features
- Implemented Codex Cursor compatibility and Next.js 16 proxy migration updates (`e9b0a73`, `7b864a9`, `1c6dd6d`).
- Added OpenAI-compatible provider nodes with CRUD/validation/test coverage in API and UI (`0a28f9f`).
- Added token expiration and key-validity checks in provider test flow (`686585d`).
- Added Kiro token refresh support in shared token refresh service (`f2ca6f0`).
- Added non-streaming response translation support for multiple formats (`63f2da8`).
- Updated Kiro OAuth wiring and auth-related UI assets/components (`31cc79a`).
## Fixes
- Fixed cloud translation/request compatibility path (`c7219d0`).
- Fixed Kiro auth modal/flow issues (`85b7bb9`).
- Included Antigravity stability fixes in translator/executor flow (`2393771`, `8c37b39`).
# v0.2.43 (2026-01-27)
## Fixes
- Fixed CLI tools model selection behavior (`a015266`).
- Fixed Kiro translator request handling (`d3dd868`).
# v0.2.36 (2026-01-19)
## Features
- Added the Usage dashboard page and related usage stats components (`3804357`).
- Integrated outbound proxy support in Open SSE fetch pipeline (`0943387`).
- Improved OpenAI compatibility and build stability across endpoint/profile/providers flows (`d9b8e48`).
## Fixes
- Fixed combo fallback behavior (`e6ca119`).
- Resolved SonarQube findings, Next.js image warnings, and build/lint cleanups (`7058b06`, `0848dd5`).
# v0.2.31 (2026-01-18)
## Fixes
- Fixed Kiro token refresh and executor behavior (`6b22b1f`, `1d481c2`).
- Fixed Kiro request translation handling (`eff52f7`, `da15660`).
# v0.2.27 (2026-01-15)
## Features
- Added Kiro provider support with OAuth flow (`26b61e5`).
## Fixes
- Fixed Codex provider behavior (`26b61e5`).
# v0.2.21 (2026-01-12)
## Changes
- README updates.
- Antigravity bug fixes.

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . ./
RUN mkdir -p /app/data && npm run build
FROM node:22-bookworm-slim AS runner-base
WORKDIR /app
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
org.opencontainers.image.licenses="MIT"
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
# Runtime writable location for localDb when DATA_DIR is configured to /app/data
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
FROM runner-base AS runner-cli
# Install system dependencies required by openclaw (git+ssh references).
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest

1282
README.md Normal file

File diff suppressed because it is too large Load Diff

47
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,47 @@
# ──────────────────────────────────────────────────────────────────────
# OmniRoute — Docker Compose (Production Snapshot)
# ──────────────────────────────────────────────────────────────────────
#
# Isolated production instance running on port 20130.
# Keeps the app running while you continue developing locally.
#
# Usage:
# docker compose -f docker-compose.prod.yml up -d --build
# docker compose -f docker-compose.prod.yml down
# docker compose -f docker-compose.prod.yml logs -f
# ──────────────────────────────────────────────────────────────────────
services:
omniroute-prod:
container_name: omniroute-prod
build:
context: .
target: runner-base
image: omniroute:prod
restart: unless-stopped
env_file: .env
environment:
- NODE_ENV=production
- PORT=20128
- HOSTNAME=0.0.0.0
- DATA_DIR=/app/data
ports:
- "20130:20128"
volumes:
- omniroute-prod-data:/app/data
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
omniroute-prod-data:
name: omniroute-prod-data

101
docker-compose.yml Normal file
View File

@@ -0,0 +1,101 @@
# ──────────────────────────────────────────────────────────────────────
# OmniRoute — Docker Compose
# ──────────────────────────────────────────────────────────────────────
#
# Profiles:
# base → minimal image, no CLI tools
# cli → CLIs installed inside the container (portable)
# host → runner-base + host-mounted CLI binaries (Linux-first)
#
# Usage:
# docker compose --profile base up -d
# docker compose --profile cli up -d
# docker compose --profile host up -d
#
# Before first run, copy .env.example → .env and edit your secrets.
# ──────────────────────────────────────────────────────────────────────
x-common: &common
restart: unless-stopped
env_file: .env
volumes:
- omniroute-data:/app/data
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
services:
# ── Profile: base (minimal, no CLI tools) ──────────────────────────
omniroute-base:
<<: *common
container_name: omniroute
build:
context: .
target: runner-base
image: omniroute:base
ports:
- "${PORT:-20128}:20128"
profiles:
- base
# ── Profile: cli (CLIs installed inside container) ─────────────────
omniroute-cli:
<<: *common
container_name: omniroute
build:
context: .
target: runner-cli
image: omniroute:cli
ports:
- "${PORT:-20128}:20128"
profiles:
- cli
# ── Profile: host (host-mounted CLI binaries, Linux-first) ────────
omniroute-host:
<<: *common
container_name: omniroute
build:
context: .
target: runner-base
image: omniroute:base
ports:
- "${PORT:-20128}:20128"
environment:
- CLI_MODE=host
- CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin
- CLI_CONFIG_HOME=/host-home
- CLI_ALLOW_CONFIG_WRITES=true
# Uncomment per-tool overrides as needed:
# - CLI_CURSOR_BIN=agent
# - CLI_CLINE_BIN=cline
# - CLI_CONTINUE_BIN=cn
volumes:
- omniroute-data:/app/data
# ── Host binary mounts (read-only) ──
# Adjust paths below to match YOUR host system.
- ~/.local/bin:/host-local/bin:ro
# Node global binaries (adjust node version path)
# - ~/.nvm/versions/node/v22.16.0/bin:/host-node/bin:ro
# ── Host config mounts (read-write) ──
- ~/.codex:/host-home/.codex:rw
- ~/.claude:/host-home/.claude:rw
- ~/.factory:/host-home/.factory:rw
- ~/.openclaw:/host-home/.openclaw:rw
- ~/.cursor:/host-home/.cursor:rw
- ~/.config/cursor:/host-home/.config/cursor:rw
profiles:
- host
volumes:
omniroute-data:
name: omniroute-data

666
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,666 @@
# OmniRoute Architecture
_Last updated: 2026-02-09_
## Executive Summary
OmniRoute is a local AI routing gateway and dashboard built on Next.js.
It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic across multiple upstream providers with translation, fallback, token refresh, and usage tracking.
Core capabilities:
- OpenAI-compatible API surface for CLI/tools (28 providers)
- Request/response translation across provider formats
- Model combo fallback (multi-model sequence)
- Account-level fallback (multi-account per provider)
- OAuth + API-key provider connection management
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
- Image generation via `/v1/images/generations` (4 providers, 9 models)
- Think tag parsing (`<think>...</think>`) for reasoning models
- Local persistence for providers, keys, aliases, combos, settings, pricing
- Usage/cost tracking and request logging
- Optional cloud sync for multi-device/state sync
Primary runtime model:
- Next.js app routes under `src/app/api/*` implement both dashboard APIs and compatibility APIs
- A shared SSE/routing core in `src/sse/*` + `open-sse/*` handles provider execution, translation, streaming, fallback, and usage
## Scope and Boundaries
### In Scope
- Local gateway runtime
- Dashboard management APIs
- Provider authentication and token refresh
- Request translation and SSE streaming
- Local state + usage persistence
- Optional cloud sync orchestration
### Out of Scope
- Cloud service implementation behind `NEXT_PUBLIC_CLOUD_URL`
- Provider SLA/control plane outside local process
- External CLI binaries themselves (Claude CLI, Codex CLI, etc.)
## High-Level System Context
```mermaid
flowchart LR
subgraph Clients[Developer Clients]
C1[Claude Code]
C2[Codex CLI]
C3[OpenClaw / Droid / Cline / Continue / Roo]
C4[Custom OpenAI-compatible clients]
BROWSER[Browser Dashboard]
end
subgraph Router[OmniRoute Local Process]
API[V1 Compatibility API\n/v1/*]
DASH[Dashboard + Management API\n/api/*]
CORE[SSE + Translation Core\nopen-sse + src/sse]
DB[(db.json)]
UDB[(usage.json + log.txt)]
end
subgraph Upstreams[Upstream Providers]
P1[OAuth Providers\nClaude/Codex/Gemini/Qwen/iFlow/GitHub/Kiro/Cursor/Antigravity]
P2[API Key Providers\nOpenAI/Anthropic/OpenRouter/GLM/Kimi/MiniMax\nDeepSeek/Groq/xAI/Mistral/Perplexity\nTogether/Fireworks/Cerebras/Cohere/NVIDIA]
P3[Compatible Nodes\nOpenAI-compatible / Anthropic-compatible]
end
subgraph Cloud[Optional Cloud Sync]
CLOUD[Cloud Sync Endpoint\nNEXT_PUBLIC_CLOUD_URL]
end
C1 --> API
C2 --> API
C3 --> API
C4 --> API
BROWSER --> DASH
API --> CORE
DASH --> DB
CORE --> DB
CORE --> UDB
CORE --> P1
CORE --> P2
CORE --> P3
DASH --> CLOUD
```
## Core Runtime Components
## 1) API and Routing Layer (Next.js App Routes)
Main directories:
- `src/app/api/v1/*` and `src/app/api/v1beta/*` for compatibility APIs
- `src/app/api/*` for management/configuration APIs
- Next rewrites in `next.config.mjs` map `/v1/*` to `/api/v1/*`
Important compatibility routes:
- `src/app/api/v1/chat/completions/route.js`
- `src/app/api/v1/messages/route.js`
- `src/app/api/v1/responses/route.js`
- `src/app/api/v1/models/route.js` — includes custom models with `custom: true`
- `src/app/api/v1/embeddings/route.js` — embedding generation (6 providers)
- `src/app/api/v1/images/generations/route.js` — image generation (4+ providers incl. Antigravity/Nebius)
- `src/app/api/v1/messages/count_tokens/route.js`
- `src/app/api/v1/providers/[provider]/chat/completions/route.js` — dedicated per-provider chat
- `src/app/api/v1/providers/[provider]/embeddings/route.js` — dedicated per-provider embeddings
- `src/app/api/v1/providers/[provider]/images/generations/route.js` — dedicated per-provider images
- `src/app/api/v1beta/models/route.js`
- `src/app/api/v1beta/models/[...path]/route.js`
Management domains:
- Auth/settings: `src/app/api/auth/*`, `src/app/api/settings/*`
- Providers/connections: `src/app/api/providers*`
- Provider nodes: `src/app/api/provider-nodes*`
- Custom models: `src/app/api/provider-models` (GET/POST/DELETE)
- Model catalog: `src/app/api/models/catalog` (GET)
- Proxy config: `src/app/api/settings/proxy` (GET/PUT/DELETE) + `src/app/api/settings/proxy/test` (POST)
- OAuth: `src/app/api/oauth/*`
- Keys/aliases/combos/pricing: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing`
- Usage: `src/app/api/usage/*`
- Sync/cloud: `src/app/api/sync/*`, `src/app/api/cloud/*`
- CLI tooling helpers: `src/app/api/cli-tools/*`
## 2) SSE + Translation Core
Main flow modules:
- Entry: `src/sse/handlers/chat.js`
- Core orchestration: `open-sse/handlers/chatCore.js`
- Provider execution adapters: `open-sse/executors/*`
- Format detection/provider config: `open-sse/services/provider.js`
- Model parse/resolve: `src/sse/services/model.js`, `open-sse/services/model.js`
- Account fallback logic: `open-sse/services/accountFallback.js`
- Translation registry: `open-sse/translator/index.js`
- Stream transformations: `open-sse/utils/stream.js`, `open-sse/utils/streamHandler.js`
- Usage extraction/normalization: `open-sse/utils/usageTracking.js`
- Think tag parser: `open-sse/utils/thinkTagParser.js`
- Embedding handler: `open-sse/handlers/embeddings.js`
- Embedding provider registry: `open-sse/config/embeddingRegistry.js`
- Image generation handler: `open-sse/handlers/imageGeneration.js`
- Image provider registry: `open-sse/config/imageRegistry.js`
## 3) Persistence Layer
Primary state DB:
- `src/lib/localDb.js`
- file: `${DATA_DIR}/db.json` (or `$XDG_CONFIG_HOME/omniroute/db.json` when set, else `~/.omniroute/db.json`)
- entities: providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing, **customModels**, **proxyConfig**
Usage DB:
- `src/lib/usageDb.js`
- files: `${DATA_DIR}/usage.json`, `${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`
- follows same base directory policy as `localDb` (`DATA_DIR`, then `XDG_CONFIG_HOME/omniroute` when set)
## 4) Auth + Security Surfaces
- Dashboard cookie auth: `src/proxy.js`, `src/app/api/auth/login/route.js`
- API key generation/verification: `src/shared/utils/apiKey.js`
- Provider secrets persisted in `providerConnections` entries
- Outbound proxy support via `open-sse/utils/proxyFetch.js` (env vars) and `open-sse/utils/networkProxy.js` (configurable per-provider or global)
## 5) Cloud Sync
- Scheduler init: `src/lib/initCloudSync.js`, `src/shared/services/initializeCloudSync.js`
- Periodic task: `src/shared/services/cloudSyncScheduler.js`
- Control route: `src/app/api/sync/cloud/route.js`
## Request Lifecycle (`/v1/chat/completions`)
```mermaid
sequenceDiagram
autonumber
participant Client as CLI/SDK Client
participant Route as /api/v1/chat/completions
participant Chat as src/sse/handlers/chat
participant Core as open-sse/handlers/chatCore
participant Model as Model Resolver
participant Auth as Credential Selector
participant Exec as Provider Executor
participant Prov as Upstream Provider
participant Stream as Stream Translator
participant Usage as usageDb
Client->>Route: POST /v1/chat/completions
Route->>Chat: handleChat(request)
Chat->>Model: parse/resolve model or combo
alt Combo model
Chat->>Chat: iterate combo models (handleComboChat)
end
Chat->>Auth: getProviderCredentials(provider)
Auth-->>Chat: active account + tokens/api key
Chat->>Core: handleChatCore(body, modelInfo, credentials)
Core->>Core: detect source format
Core->>Core: translate request to target format
Core->>Exec: execute(provider, transformedBody)
Exec->>Prov: upstream API call
Prov-->>Exec: SSE/JSON response
Exec-->>Core: response + metadata
alt 401/403
Core->>Exec: refreshCredentials()
Exec-->>Core: updated tokens
Core->>Exec: retry request
end
Core->>Stream: translate/normalize stream to client format
Stream-->>Client: SSE chunks / JSON response
Stream->>Usage: extract usage + persist history/log
```
## Combo + Account Fallback Flow
```mermaid
flowchart TD
A[Incoming model string] --> B{Is combo name?}
B -- Yes --> C[Load combo models sequence]
B -- No --> D[Single model path]
C --> E[Try model N]
E --> F[Resolve provider/model]
D --> F
F --> G[Select account credentials]
G --> H{Credentials available?}
H -- No --> I[Return provider unavailable]
H -- Yes --> J[Execute request]
J --> K{Success?}
K -- Yes --> L[Return response]
K -- No --> M{Fallback-eligible error?}
M -- No --> N[Return error]
M -- Yes --> O[Mark account unavailable cooldown]
O --> P{Another account for provider?}
P -- Yes --> G
P -- No --> Q{In combo with next model?}
Q -- Yes --> E
Q -- No --> R[Return all unavailable]
```
Fallback decisions are driven by `open-sse/services/accountFallback.js` using status codes and error-message heuristics.
## OAuth Onboarding and Token Refresh Lifecycle
```mermaid
sequenceDiagram
autonumber
participant UI as Dashboard UI
participant OAuth as /api/oauth/[provider]/[action]
participant ProvAuth as Provider Auth Server
participant DB as localDb
participant Test as /api/providers/[id]/test
participant Exec as Provider Executor
UI->>OAuth: GET authorize or device-code
OAuth->>ProvAuth: create auth/device flow
ProvAuth-->>OAuth: auth URL or device code payload
OAuth-->>UI: flow data
UI->>OAuth: POST exchange or poll
OAuth->>ProvAuth: token exchange/poll
ProvAuth-->>OAuth: access/refresh tokens
OAuth->>DB: createProviderConnection(oauth data)
OAuth-->>UI: success + connection id
UI->>Test: POST /api/providers/[id]/test
Test->>Exec: validate credentials / optional refresh
Exec-->>Test: valid or refreshed token info
Test->>DB: update status/tokens/errors
Test-->>UI: validation result
```
Refresh during live traffic is executed inside `open-sse/handlers/chatCore.js` via executor `refreshCredentials()`.
## Cloud Sync Lifecycle (Enable / Sync / Disable)
```mermaid
sequenceDiagram
autonumber
participant UI as Endpoint Page UI
participant Sync as /api/sync/cloud
participant DB as localDb
participant Cloud as External Cloud Sync
participant Claude as ~/.claude/settings.json
UI->>Sync: POST action=enable
Sync->>DB: set cloudEnabled=true
Sync->>DB: ensure API key exists
Sync->>Cloud: POST /sync/{machineId} (providers/aliases/combos/keys)
Cloud-->>Sync: sync result
Sync->>Cloud: GET /{machineId}/v1/verify
Sync-->>UI: enabled + verification status
UI->>Sync: POST action=sync
Sync->>Cloud: POST /sync/{machineId}
Cloud-->>Sync: remote data
Sync->>DB: update newer local tokens/status
Sync-->>UI: synced
UI->>Sync: POST action=disable
Sync->>DB: set cloudEnabled=false
Sync->>Cloud: DELETE /sync/{machineId}
Sync->>Claude: switch ANTHROPIC_BASE_URL back to local (if needed)
Sync-->>UI: disabled
```
Periodic sync is triggered by `CloudSyncScheduler` when cloud is enabled.
## Data Model and Storage Map
```mermaid
erDiagram
SETTINGS ||--o{ PROVIDER_CONNECTION : controls
PROVIDER_NODE ||--o{ PROVIDER_CONNECTION : backs_compatible_provider
PROVIDER_CONNECTION ||--o{ USAGE_ENTRY : emits_usage
SETTINGS {
boolean cloudEnabled
number stickyRoundRobinLimit
boolean requireLogin
string password_hash
}
PROVIDER_CONNECTION {
string id
string provider
string authType
string name
number priority
boolean isActive
string apiKey
string accessToken
string refreshToken
string expiresAt
string testStatus
string lastError
string rateLimitedUntil
json providerSpecificData
}
PROVIDER_NODE {
string id
string type
string name
string prefix
string apiType
string baseUrl
}
MODEL_ALIAS {
string alias
string targetModel
}
COMBO {
string id
string name
string[] models
}
API_KEY {
string id
string name
string key
string machineId
}
USAGE_ENTRY {
string provider
string model
number prompt_tokens
number completion_tokens
string connectionId
string timestamp
}
CUSTOM_MODEL {
string id
string name
string providerId
}
PROXY_CONFIG {
string global
json providers
}
```
Physical storage files:
- main state: `${DATA_DIR}/db.json` (or `$XDG_CONFIG_HOME/omniroute/db.json` when set, else `~/.omniroute/db.json`)
- usage stats: `${DATA_DIR}/usage.json`
- request log lines: `${DATA_DIR}/log.txt`
- optional translator/request debug sessions: `<repo>/logs/...`
## Deployment Topology
```mermaid
flowchart LR
subgraph LocalHost[Developer Host]
CLI[CLI Tools]
Browser[Dashboard Browser]
end
subgraph ContainerOrProcess[OmniRoute Runtime]
Next[Next.js Server\nPORT=20128]
Core[SSE Core + Executors]
MainDB[(db.json)]
UsageDB[(usage.json/log.txt)]
end
subgraph External[External Services]
Providers[AI Providers]
SyncCloud[Cloud Sync Service]
end
CLI --> Next
Browser --> Next
Next --> Core
Next --> MainDB
Core --> MainDB
Core --> UsageDB
Core --> Providers
Next --> SyncCloud
```
## Module Mapping (Decision-Critical)
### Route and API Modules
- `src/app/api/v1/*`, `src/app/api/v1beta/*`: compatibility APIs
- `src/app/api/v1/providers/[provider]/*`: dedicated per-provider routes (chat, embeddings, images)
- `src/app/api/providers*`: provider CRUD, validation, testing
- `src/app/api/provider-nodes*`: custom compatible node management
- `src/app/api/provider-models`: custom model management (CRUD)
- `src/app/api/models/catalog`: full model catalog API (all types grouped by provider)
- `src/app/api/oauth/*`: OAuth/device-code flows
- `src/app/api/keys*`: local API key lifecycle
- `src/app/api/models/alias`: alias management
- `src/app/api/combos*`: fallback combo management
- `src/app/api/pricing`: pricing overrides for cost calculation
- `src/app/api/settings/proxy`: proxy configuration (GET/PUT/DELETE)
- `src/app/api/settings/proxy/test`: outbound proxy connectivity test (POST)
- `src/app/api/usage/*`: usage and logs APIs
- `src/app/api/sync/*` + `src/app/api/cloud/*`: cloud sync and cloud-facing helpers
- `src/app/api/cli-tools/*`: local CLI config writers/checkers
### Routing and Execution Core
- `src/sse/handlers/chat.js`: request parse, combo handling, account selection loop
- `open-sse/handlers/chatCore.js`: translation, executor dispatch, retry/refresh handling, stream setup
- `open-sse/executors/*`: provider-specific network and format behavior
### Translation Registry and Format Converters
- `open-sse/translator/index.js`: translator registry and orchestration
- Request translators: `open-sse/translator/request/*`
- Response translators: `open-sse/translator/response/*`
- Format constants: `open-sse/translator/formats.js`
### Persistence
- `src/lib/localDb.js`: persistent config/state
- `src/lib/usageDb.js`: usage history and rolling request logs
## Provider Executor Coverage (Strategy Pattern)
Each provider has a specialized executor extending `BaseExecutor` (in `open-sse/executors/base.js`), which provides URL building, header construction, retry with exponential backoff, credential refresh hooks, and the `execute()` orchestration method.
| Executor | Provider(s) | Special Handling |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, iFlow, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA | Dynamic URL/header config per provider |
| `AntigravityExecutor` | Google Antigravity | Custom project/session IDs, Retry-After parsing |
| `CodexExecutor` | OpenAI Codex | Injects system instructions, forces reasoning effort |
| `CursorExecutor` | Cursor IDE | ConnectRPC protocol, Protobuf encoding, request signing via checksum |
| `GithubExecutor` | GitHub Copilot | Copilot token refresh, VSCode-mimicking headers |
| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream binary format → SSE conversion |
| `GeminiCLIExecutor` | Gemini CLI | Google OAuth token refresh cycle |
All other providers (including custom compatible nodes) use the `DefaultExecutor`.
## Provider Compatibility Matrix
| Provider | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API |
| ---------------- | ---------------- | --------------------- | ---------------- | ---------- | ------------- | ------------------ |
| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Admin only |
| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console |
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console |
| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Full quota API |
| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Codex | openai-responses | OAuth | ✅ forced | ❌ | ✅ | ✅ Rate limits |
| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Quota snapshots |
| Cursor | cursor | Custom checksum | ✅ | ✅ | ❌ | ❌ |
| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Usage limits |
| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Per request |
| iFlow | openai | OAuth (Basic) | ✅ | ✅ | ✅ | ⚠️ Per request |
| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ |
| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ |
| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ |
## Format Translation Coverage
Detected source formats include:
- `openai`
- `openai-responses`
- `claude`
- `gemini`
Target formats include:
- OpenAI chat/Responses
- Claude
- Gemini/Gemini-CLI/Antigravity envelope
- Kiro
- Cursor
Translations use **OpenAI as the hub format** — all conversions go through OpenAI as intermediate:
```
Source Format → OpenAI (hub) → Target Format
```
Translations are selected dynamically based on source payload shape and provider target format.
## Supported API Endpoints
| Endpoint | Format | Handler |
| -------------------------------------------------- | ------------------ | ---------------------------------------------------- |
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.js` |
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.js` |
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.js` |
| `GET /v1/embeddings` | Model listing | API route |
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.js` |
| `GET /v1/images/generations` | Model listing | API route |
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
| `GET/POST/DELETE /api/provider-models` | Custom Models | Custom model management per provider |
## Bypass Handler
The bypass handler (`open-sse/utils/bypassHandler.js`) intercepts known "throwaway" requests from Claude CLI — warmup pings, title extractions, and token counts — and returns a **fake response** without consuming upstream provider tokens. This is triggered only when `User-Agent` contains `claude-cli`.
## Request Logger Pipeline
The request logger (`open-sse/utils/requestLogger.js`) provides a 7-stage debug logging pipeline, disabled by default, enabled via `ENABLE_REQUEST_LOGS=true`:
```
1_req_client.json → 2_req_source.json → 3_req_openai.json → 4_req_target.json
→ 5_res_provider.txt → 6_res_openai.txt → 7_res_client.txt
```
Files are written to `<repo>/logs/<session>/` for each request session.
## Failure Modes and Resilience
## 1) Account/Provider Availability
- provider account cooldown on transient/rate/auth errors
- account fallback before failing request
- combo model fallback when current model/provider path is exhausted
## 2) Token Expiry
- pre-check and refresh with retry for refreshable providers
- 401/403 retry after refresh attempt in core path
## 3) Stream Safety
- disconnect-aware stream controller
- translation stream with end-of-stream flush and `[DONE]` handling
- usage estimation fallback when provider usage metadata is missing
## 4) Cloud Sync Degradation
- sync errors are surfaced but local runtime continues
- scheduler has retry-capable logic, but periodic execution currently calls single-attempt sync by default
## 5) Data Integrity
- DB shape migration/repair for missing keys
- corrupt JSON reset safeguards for localDb and usageDb
## Observability and Operational Signals
Runtime visibility sources:
- console logs from `src/sse/utils/logger.js`
- per-request usage aggregates in `usage.json`
- textual request status log in `log.txt`
- optional deep request/translation logs under `logs/` when `ENABLE_REQUEST_LOGS=true`
- dashboard usage endpoints (`/api/usage/*`) for UI consumption
## Security-Sensitive Boundaries
- JWT secret (`JWT_SECRET`) secures dashboard session cookie verification/signing
- Initial password fallback (`INITIAL_PASSWORD`, default `123456`) must be overridden in real deployments
- API key HMAC secret (`API_KEY_SECRET`) secures generated local API key format
- Provider secrets (API keys/tokens) are persisted in local DB and should be protected at filesystem level
- Cloud sync endpoints rely on API key auth + machine id semantics
## Environment and Runtime Matrix
Environment variables actively used by code:
- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD`
- Storage: `DATA_DIR`
- Compatible node behavior: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE`
- Optional storage base override (Linux/macOS when `DATA_DIR` unset): `XDG_CONFIG_HOME`
- Security hashing: `API_KEY_SECRET`, `MACHINE_ID_SALT`
- Logging: `ENABLE_REQUEST_LOGS`
- Sync/cloud URLing: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL`
- Outbound proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` and lowercase variants
- SOCKS5 feature flags: `ENABLE_SOCKS5_PROXY`, `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY`
- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME`
## Known Architectural Notes
1. `usageDb` and `localDb` now share the same base directory policy (`DATA_DIR` -> `XDG_CONFIG_HOME/omniroute` -> `~/.omniroute`) with legacy file migration.
2. `/api/v1/route.js` returns a static model list and is not the main models source used by `/v1/models`.
3. Request logger writes full headers/body when enabled; treat log directory as sensitive.
4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability.
5. The `open-sse/` directory is published as the `@omniroute/open-sse` **npm workspace package**. Source code imports it via `@omniroute/open-sse/...` (resolved by Next.js `transpilePackages`). File paths in this document still use the directory name `open-sse/` for consistency.
6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive visualizations (bar charts, donut charts).
7. E2E tests use **Playwright** (`tests/e2e/`), run via `npm run test:e2e`. Unit tests use **Node.js test runner** (`tests/unit/`), run via `npm run test:plan3`.
## Operational Verification Checklist
- Build from source: `cd /root/dev/omniroute && npm run build`
- Build Docker image: `cd /root/dev/omniroute && docker build -t omniroute .`
- Start service and verify:
- `GET /api/settings`
- `GET /api/v1/models`
- CLI target base URL should be `http://<host>:20128/v1` when `PORT=20128`

View File

@@ -0,0 +1,574 @@
# omniroute — Codebase Documentation
> A comprehensive, beginner-friendly guide to the **omniroute** multi-provider AI proxy router.
---
## 1. What Is omniroute?
omniroute is a **proxy router** that sits between AI clients (Claude CLI, Codex, Cursor IDE, etc.) and AI providers (Anthropic, Google, OpenAI, AWS, GitHub, etc.). It solves one big problem:
> **Different AI clients speak different "languages" (API formats), and different AI providers expect different "languages" too.** omniroute translates between them automatically.
Think of it like a universal translator at the United Nations — any delegate can speak any language, and the translator converts it for any other delegate.
---
## 2. Architecture Overview
```mermaid
graph LR
subgraph Clients
A[Claude CLI]
B[Codex]
C[Cursor IDE]
D[OpenAI-compatible]
end
subgraph omniroute
E[Handler Layer]
F[Translator Layer]
G[Executor Layer]
H[Services Layer]
end
subgraph Providers
I[Anthropic Claude]
J[Google Gemini]
K[OpenAI / Codex]
L[GitHub Copilot]
M[AWS Kiro]
N[Antigravity]
O[Cursor API]
end
A --> E
B --> E
C --> E
D --> E
E --> F
F --> G
G --> I
G --> J
G --> K
G --> L
G --> M
G --> N
G --> O
H -.-> E
H -.-> G
```
### Core Principle: Hub-and-Spoke Translation
All format translation passes through **OpenAI format as the hub**:
```
Client Format → [OpenAI Hub] → Provider Format (request)
Provider Format → [OpenAI Hub] → Client Format (response)
```
This means you only need **N translators** (one per format) instead of **N²** (every pair).
---
## 3. Project Structure
```
omniroute/
├── open-sse/ ← Core proxy library (portable, framework-agnostic)
│ ├── index.js ← Main entry point, exports everything
│ ├── config/ ← Configuration & constants
│ ├── executors/ ← Provider-specific request execution
│ ├── handlers/ ← Request handling orchestration
│ ├── services/ ← Business logic (auth, models, fallback, usage)
│ ├── translator/ ← Format translation engine
│ │ ├── request/ ← Request translators (8 files)
│ │ ├── response/ ← Response translators (7 files)
│ │ └── helpers/ ← Shared translation utilities (6 files)
│ └── utils/ ← Utility functions
├── src/ ← Application layer (Express/Worker runtime)
│ ├── app/ ← Web UI, API routes, middleware
│ ├── lib/ ← Database, auth, and shared library code
│ ├── mitm/ ← Man-in-the-middle proxy utilities
│ ├── models/ ← Database models
│ ├── shared/ ← Shared utilities (wrappers around open-sse)
│ ├── sse/ ← SSE endpoint handlers
│ └── store/ ← State management
├── data/ ← Runtime data (credentials, logs)
│ └── provider-credentials.json (external credentials override, gitignored)
└── tester/ ← Test utilities
```
---
## 4. Module-by-Module Breakdown
### 4.1 Config (`open-sse/config/`)
The **single source of truth** for all provider configuration.
| File | Purpose |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `constants.js` | `PROVIDERS` object with base URLs, OAuth credentials (defaults), headers, and default system prompts for every provider. Also defines `HTTP_STATUS`, `ERROR_TYPES`, `COOLDOWN_MS`, `BACKOFF_CONFIG`, and `SKIP_PATTERNS`. |
| `credentialLoader.js` | Loads external credentials from `data/provider-credentials.json` and merges them over the hardcoded defaults in `PROVIDERS`. Keeps secrets out of source control while maintaining backwards compatibility. |
| `providerModels.js` | Central model registry: maps provider aliases → model IDs. Functions like `getModels()`, `getProviderByAlias()`. |
| `codexInstructions.js` | System instructions injected into Codex requests (editing constraints, sandbox rules, approval policies). |
| `defaultThinkingSignature.js` | Default "thinking" signatures for Claude and Gemini models. |
| `ollamaModels.js` | Schema definition for local Ollama models (name, size, family, quantization). |
#### Credential Loading Flow
```mermaid
flowchart TD
A["App starts"] --> B["constants.js defines PROVIDERS\nwith hardcoded defaults"]
B --> C{"data/provider-credentials.json\nexists?"}
C -->|Yes| D["credentialLoader reads JSON"]
C -->|No| E["Use hardcoded defaults"]
D --> F{"For each provider in JSON"}
F --> G{"Provider exists\nin PROVIDERS?"}
G -->|No| H["Log warning, skip"]
G -->|Yes| I{"Value is object?"}
I -->|No| J["Log warning, skip"]
I -->|Yes| K["Merge clientId, clientSecret,\ntokenUrl, authUrl, refreshUrl"]
K --> F
H --> F
J --> F
F -->|Done| L["PROVIDERS ready with\nmerged credentials"]
E --> L
```
---
### 4.2 Executors (`open-sse/executors/`)
Executors encapsulate **provider-specific logic** using the **Strategy Pattern**. Each executor overrides base methods as needed.
```mermaid
classDiagram
class BaseExecutor {
+buildUrl(model, stream, options)
+buildHeaders(credentials, stream, body)
+transformRequest(body, model, stream, credentials)
+execute(url, options)
+shouldRetry(status, error)
+refreshCredentials(credentials, log)
}
class DefaultExecutor {
+refreshCredentials()
}
class AntigravityExecutor {
+buildUrl()
+buildHeaders()
+transformRequest()
+shouldRetry()
+refreshCredentials()
}
class CursorExecutor {
+buildUrl()
+buildHeaders()
+transformRequest()
+parseResponse()
+generateChecksum()
}
class KiroExecutor {
+buildUrl()
+buildHeaders()
+transformRequest()
+parseEventStream()
+refreshCredentials()
}
BaseExecutor <|-- DefaultExecutor
BaseExecutor <|-- AntigravityExecutor
BaseExecutor <|-- CursorExecutor
BaseExecutor <|-- KiroExecutor
BaseExecutor <|-- CodexExecutor
BaseExecutor <|-- GeminiCLIExecutor
BaseExecutor <|-- GithubExecutor
```
| Executor | Provider | Key Specializations |
| ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `base.js` | — | Abstract base: URL building, headers, retry logic, credential refresh |
| `default.js` | Claude, Gemini, OpenAI, GLM, Kimi, MiniMax | Generic OAuth token refresh for standard providers |
| `antigravity.js` | Google Cloud Code | Project/session ID generation, multi-URL fallback, custom retry parsing from error messages ("reset after 2h7m23s") |
| `cursor.js` | Cursor IDE | **Most complex**: SHA-256 checksum auth, Protobuf request encoding, binary EventStream → SSE response parsing |
| `codex.js` | OpenAI Codex | Injects system instructions, manages thinking levels, removes unsupported parameters |
| `gemini-cli.js` | Google Gemini CLI | Custom URL building (`streamGenerateContent`), Google OAuth token refresh |
| `github.js` | GitHub Copilot | Dual token system (GitHub OAuth + Copilot token), VSCode header mimicking |
| `kiro.js` | AWS CodeWhisperer | AWS EventStream binary parsing, AMZN event frames, token estimation |
| `index.js` | — | Factory: maps provider name → executor class, with default fallback |
---
### 4.3 Handlers (`open-sse/handlers/`)
The **orchestration layer** — coordinates translation, execution, streaming, and error handling.
| File | Purpose |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatCore.js` | **Central orchestrator** (~600 lines). Handles the complete request lifecycle: format detection → translation → executor dispatch → streaming/non-streaming response → token refresh → error handling → usage logging. |
| `responsesHandler.js` | Adapter for OpenAI's Responses API: converts Responses format → Chat Completions → sends to `chatCore` → converts SSE back to Responses format. |
| `embeddings.js` | Embedding generation handler: resolves embedding model → provider, dispatches to provider API, returns OpenAI-compatible embedding response. Supports 6+ providers. |
| `imageGeneration.js` | Image generation handler: resolves image model → provider, supports OpenAI-compatible, Gemini-image (Antigravity), and fallback (Nebius) modes. Returns base64 or URL images. |
#### Request Lifecycle (chatCore.js)
```mermaid
sequenceDiagram
participant Client
participant chatCore
participant Translator
participant Executor
participant Provider
Client->>chatCore: Request (any format)
chatCore->>chatCore: Detect source format
chatCore->>chatCore: Check bypass patterns
chatCore->>chatCore: Resolve model & provider
chatCore->>Translator: Translate request (source → OpenAI → target)
chatCore->>Executor: Get executor for provider
Executor->>Executor: Build URL, headers, transform request
Executor->>Executor: Refresh credentials if needed
Executor->>Provider: HTTP fetch (streaming or non-streaming)
alt Streaming
Provider-->>chatCore: SSE stream
chatCore->>chatCore: Pipe through SSE transform stream
Note over chatCore: Transform stream translates<br/>each chunk: target → OpenAI → source
chatCore-->>Client: Translated SSE stream
else Non-streaming
Provider-->>chatCore: JSON response
chatCore->>Translator: Translate response
chatCore-->>Client: Translated JSON
end
alt Error (401, 429, 500...)
chatCore->>Executor: Retry with credential refresh
chatCore->>chatCore: Account fallback logic
end
```
---
### 4.4 Services (`open-sse/services/`)
Business logic that supports the handlers and executors.
| File | Purpose |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider.js` | **Format detection** (`detectFormat`): analyzes request body structure to identify Claude/OpenAI/Gemini/Antigravity/Responses formats (includes `max_tokens` heuristic for Claude). Also: URL building, header building, thinking config normalization. Supports `openai-compatible-*` and `anthropic-compatible-*` dynamic providers. |
| `model.js` | Model string parsing (`claude/model-name``{provider: "claude", model: "model-name"}`), alias resolution with collision detection, input sanitization (rejects path traversal/control chars), and model info resolution with async alias getter support. |
| `accountFallback.js` | Rate-limit handling: exponential backoff (1s → 2s → 4s → max 2min), account cooldown management, error classification (which errors trigger fallback vs. not). |
| `tokenRefresh.js` | OAuth token refresh for **every provider**: Google (Gemini, Antigravity), Claude, Codex, Qwen, iFlow, GitHub (OAuth + Copilot dual-token), Kiro (AWS SSO OIDC + Social Auth). Includes in-flight promise deduplication cache and retry with exponential backoff. |
| `combo.js` | **Combo models**: chains of fallback models. If model A fails with a fallback-eligible error, try model B, then C, etc. Returns actual upstream status codes. |
| `usage.js` | Fetches quota/usage data from provider APIs (GitHub Copilot quotas, Antigravity model quotas, Codex rate limits, Kiro usage breakdowns, Claude settings). |
#### Token Refresh Deduplication
```mermaid
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant Cache as refreshPromiseCache
participant OAuth as OAuth Provider
R1->>Cache: getAccessToken("gemini", token)
Cache->>Cache: No in-flight promise
Cache->>OAuth: Start refresh
R2->>Cache: getAccessToken("gemini", token)
Cache->>Cache: Found in-flight promise
Cache-->>R2: Return existing promise
OAuth-->>Cache: New access token
Cache-->>R1: New access token
Cache-->>R2: Same access token (shared)
Cache->>Cache: Delete cache entry
```
#### Account Fallback State Machine
```mermaid
stateDiagram-v2
[*] --> Active
Active --> Error: Request fails (401/429/500)
Error --> Cooldown: Apply backoff
Cooldown --> Active: Cooldown expires
Active --> Active: Request succeeds (reset backoff)
state Error {
[*] --> ClassifyError
ClassifyError --> ShouldFallback: Rate limit / Auth / Transient
ClassifyError --> NoFallback: 400 Bad Request
}
state Cooldown {
[*] --> ExponentialBackoff
ExponentialBackoff: Level 0 = 1s
ExponentialBackoff: Level 1 = 2s
ExponentialBackoff: Level 2 = 4s
ExponentialBackoff: Max = 2min
}
```
#### Combo Model Chain
```mermaid
flowchart LR
A["Request with\ncombo model"] --> B["Model A"]
B -->|"2xx Success"| C["Return response"]
B -->|"429/401/500"| D{"Fallback\neligible?"}
D -->|Yes| E["Model B"]
D -->|No| F["Return error"]
E -->|"2xx Success"| C
E -->|"429/401/500"| G{"Fallback\neligible?"}
G -->|Yes| H["Model C"]
G -->|No| F
H -->|"2xx Success"| C
H -->|"Fail"| I["All failed →\nReturn last status"]
```
---
### 4.5 Translator (`open-sse/translator/`)
The **format translation engine** using a self-registering plugin system.
#### Architecture
```mermaid
graph TD
subgraph "Request Translation"
A["Claude → OpenAI"]
B["Gemini → OpenAI"]
C["Antigravity → OpenAI"]
D["OpenAI Responses → OpenAI"]
E["OpenAI → Claude"]
F["OpenAI → Gemini"]
G["OpenAI → Kiro"]
H["OpenAI → Cursor"]
end
subgraph "Response Translation"
I["Claude → OpenAI"]
J["Gemini → OpenAI"]
K["Kiro → OpenAI"]
L["Cursor → OpenAI"]
M["OpenAI → Claude"]
N["OpenAI → Antigravity"]
O["OpenAI → Responses"]
end
```
| Directory | Files | Description |
| ------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request/` | 8 translators | Convert request bodies between formats. Each file self-registers via `register(from, to, fn)` on import. |
| `response/` | 7 translators | Convert streaming response chunks between formats. Handles SSE event types, thinking blocks, tool calls. |
| `helpers/` | 6 helpers | Shared utilities: `claudeHelper` (system prompt extraction, thinking config), `geminiHelper` (parts/contents mapping), `openaiHelper` (format filtering), `toolCallHelper` (ID generation, missing response injection), `maxTokensHelper`, `responsesApiHelper`. |
| `index.js` | — | Translation engine: `translateRequest()`, `translateResponse()`, state management, registry. |
| `formats.js` | — | Format constants: `OPENAI`, `CLAUDE`, `GEMINI`, `ANTIGRAVITY`, `KIRO`, `CURSOR`, `OPENAI_RESPONSES`. |
#### Key Design: Self-Registering Plugins
```javascript
// Each translator file calls register() on import:
import { register } from "../index.js";
register("claude", "openai", translateClaudeToOpenAI);
// The index.js imports all translator files, triggering registration:
import "./request/claude-to-openai.js"; // ← self-registers
```
---
### 4.6 Utils (`open-sse/utils/`)
| File | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `error.js` | Error response building (OpenAI-compatible format), upstream error parsing, Antigravity retry-time extraction from error messages, SSE error streaming. |
| `stream.js` | **SSE Transform Stream** — the core streaming pipeline. Two modes: `TRANSLATE` (full format translation) and `PASSTHROUGH` (normalize + extract usage). Handles chunk buffering, usage estimation, content length tracking. Per-stream encoder/decoder instances avoid shared state. |
| `streamHelpers.js` | Low-level SSE utilities: `parseSSELine` (whitespace-tolerant), `hasValuableContent` (filters empty chunks for OpenAI/Claude/Gemini), `fixInvalidId`, `formatSSE` (format-aware SSE serialization with `perf_metrics` cleanup). |
| `usageTracking.js` | Token usage extraction from any format (Claude/OpenAI/Gemini/Responses), estimation with separate tool/message char-per-token ratios, buffer addition (2000 tokens safety margin), format-specific field filtering, console logging with ANSI colors. |
| `requestLogger.js` | File-based request logging (opt-in via `ENABLE_REQUEST_LOGS=true`). Creates session folders with numbered files: `1_req_client.json``7_res_client.txt`. All I/O is async (fire-and-forget). Masks sensitive headers. |
| `bypassHandler.js` | Intercepts specific patterns from Claude CLI (title extraction, warmup, count) and returns fake responses without calling any provider. Supports both streaming and non-streaming. Intentionally limited to Claude CLI scope. |
| `networkProxy.js` | Resolves outbound proxy URL for a given provider with precedence: provider-specific config → global config → environment variables (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`). Supports `NO_PROXY` exclusions. Caches config for 30s. |
#### SSE Streaming Pipeline
```mermaid
flowchart TD
A["Provider SSE stream"] --> B["TextDecoder\n(per-stream instance)"]
B --> C["Buffer lines\n(split on newline)"]
C --> D["parseSSELine()\n(trim whitespace, parse JSON)"]
D --> E{"Mode?"}
E -->|TRANSLATE| F["translateResponse()\ntarget → OpenAI → source"]
E -->|PASSTHROUGH| G["fixInvalidId()\nnormalize chunk"]
F --> H["hasValuableContent()\nfilter empty chunks"]
G --> H
H -->|"Has content"| I["extractUsage()\ntrack token counts"]
H -->|"Empty"| J["Skip chunk"]
I --> K["formatSSE()\nserialize + clean perf_metrics"]
K --> L["TextEncoder\n(per-stream instance)"]
L --> M["Enqueue to\nclient stream"]
style A fill:#f9f,stroke:#333
style M fill:#9f9,stroke:#333
```
#### Request Logger Session Structure
```
logs/
└── claude_gemini_claude-sonnet_20260208_143045/
├── 1_req_client.json ← Raw client request
├── 2_req_source.json ← After initial conversion
├── 3_req_openai.json ← OpenAI intermediate format
├── 4_req_target.json ← Final target format
├── 5_res_provider.txt ← Provider SSE chunks (streaming)
├── 5_res_provider.json ← Provider response (non-streaming)
├── 6_res_openai.txt ← OpenAI intermediate chunks
├── 7_res_client.txt ← Client-facing SSE chunks
└── 6_error.json ← Error details (if any)
```
---
### 4.7 Application Layer (`src/`)
| Directory | Purpose |
| ------------- | ---------------------------------------------------------------------- |
| `src/app/` | Web UI, API routes, Express middleware, OAuth callback handlers |
| `src/lib/` | Database access (`localDb.js`, `usageDb.js`), authentication, shared |
| `src/mitm/` | Man-in-the-middle proxy utilities for intercepting provider traffic |
| `src/models/` | Database model definitions |
| `src/shared/` | Wrappers around open-sse functions (provider, stream, error, etc.) |
| `src/sse/` | SSE endpoint handlers that wire the open-sse library to Express routes |
| `src/store/` | Application state management |
#### Notable API Routes
| Route | Methods | Purpose |
| --------------------------------------------- | --------------- | ------------------------------------------------------------------------------------- |
| `/api/provider-models` | GET/POST/DELETE | CRUD for custom models per provider |
| `/api/models/catalog` | GET | Aggregated catalog of all models (chat, embedding, image, custom) grouped by provider |
| `/api/settings/proxy` | GET/PUT/DELETE | Hierarchical outbound proxy configuration (`global/providers/combos/keys`) |
| `/api/settings/proxy/test` | POST | Validates proxy connectivity and returns public IP/latency |
| `/v1/providers/[provider]/chat/completions` | POST | Dedicated per-provider chat completions with model validation |
| `/v1/providers/[provider]/embeddings` | POST | Dedicated per-provider embeddings with model validation |
| `/v1/providers/[provider]/images/generations` | POST | Dedicated per-provider image generation with model validation |
---
## 5. Key Design Patterns
### 5.1 Hub-and-Spoke Translation
All formats translate through **OpenAI format as the hub**. Adding a new provider only requires writing **one pair** of translators (to/from OpenAI), not N pairs.
### 5.2 Executor Strategy Pattern
Each provider has a dedicated executor class inheriting from `BaseExecutor`. The factory in `executors/index.js` selects the right one at runtime.
### 5.3 Self-Registering Plugin System
Translator modules register themselves on import via `register()`. Adding a new translator is just creating a file and importing it.
### 5.4 Account Fallback with Exponential Backoff
When a provider returns 429/401/500, the system can switch to the next account, applying exponential cooldowns (1s → 2s → 4s → max 2min).
### 5.5 Combo Model Chains
A "combo" groups multiple `provider/model` strings. If the first fails, fallback to the next automatically.
### 5.6 Stateful Streaming Translation
Response translation maintains state across SSE chunks (thinking block tracking, tool call accumulation, content block indexing) via the `initState()` mechanism.
### 5.7 Usage Safety Buffer
A 2000-token buffer is added to reported usage to prevent clients from hitting context window limits due to overhead from system prompts and format translation.
---
## 6. Supported Formats
| Format | Direction | Identifier |
| ----------------------- | --------------- | ------------------ |
| OpenAI Chat Completions | source + target | `openai` |
| OpenAI Responses API | source + target | `openai-responses` |
| Anthropic Claude | source + target | `claude` |
| Google Gemini | source + target | `gemini` |
| Google Gemini CLI | target only | `gemini-cli` |
| Antigravity | source + target | `antigravity` |
| AWS Kiro | target only | `kiro` |
| Cursor | target only | `cursor` |
---
## 7. Supported Providers
| Provider | Auth Method | Executor | Key Notes |
| ------------------------ | ---------------------- | ----------- | --------------------------------------------- |
| Anthropic Claude | API key or OAuth | Default | Uses `x-api-key` header |
| Google Gemini | API key or OAuth | Default | Uses `x-goog-api-key` header |
| Google Gemini CLI | OAuth | GeminiCLI | Uses `streamGenerateContent` endpoint |
| Antigravity | OAuth | Antigravity | Multi-URL fallback, custom retry parsing |
| OpenAI | API key | Default | Standard Bearer auth |
| Codex | OAuth | Codex | Injects system instructions, manages thinking |
| GitHub Copilot | OAuth + Copilot token | Github | Dual token, VSCode header mimicking |
| Kiro (AWS) | AWS SSO OIDC or Social | Kiro | Binary EventStream parsing |
| Cursor IDE | Checksum auth | Cursor | Protobuf encoding, SHA-256 checksums |
| Qwen | OAuth | Default | Standard auth |
| iFlow | OAuth (Basic + Bearer) | Default | Dual auth header |
| OpenRouter | API key | Default | Standard Bearer auth |
| GLM, Kimi, MiniMax | API key | Default | Claude-compatible, use `x-api-key` |
| `openai-compatible-*` | API key | Default | Dynamic: any OpenAI-compatible endpoint |
| `anthropic-compatible-*` | API key | Default | Dynamic: any Claude-compatible endpoint |
---
## 8. Data Flow Summary
### Streaming Request
```mermaid
flowchart LR
A["Client"] --> B["detectFormat()"]
B --> C["translateRequest()\nsource → OpenAI → target"]
C --> D["Executor\nbuildUrl + buildHeaders"]
D --> E["fetch(providerURL)"]
E --> F["createSSEStream()\nTRANSLATE mode"]
F --> G["parseSSELine()"]
G --> H["translateResponse()\ntarget → OpenAI → source"]
H --> I["extractUsage()\n+ addBuffer"]
I --> J["formatSSE()"]
J --> K["Client receives\ntranslated SSE"]
K --> L["logUsage()\nsaveRequestUsage()"]
```
### Non-Streaming Request
```mermaid
flowchart LR
A["Client"] --> B["detectFormat()"]
B --> C["translateRequest()\nsource → OpenAI → target"]
C --> D["Executor.execute()"]
D --> E["translateResponse()\ntarget → OpenAI → source"]
E --> F["Return JSON\nresponse"]
```
### Bypass Flow (Claude CLI)
```mermaid
flowchart LR
A["Claude CLI request"] --> B{"Match bypass\npattern?"}
B -->|"Title/Warmup/Count"| C["Generate fake\nOpenAI response"]
B -->|"No match"| D["Normal flow"]
C --> E["Translate to\nsource format"]
E --> F["Return without\ncalling provider"]
```

16
eslint.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

BIN
images/omniroute.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

61
next.config.mjs Normal file
View File

@@ -0,0 +1,61 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
output: "standalone",
images: {
unoptimized: true,
},
env: {
NEXT_PUBLIC_CLOUD_URL: "https://omniroute.dev",
},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
async rewrites() {
return [
{
source: "/chat/completions",
destination: "/api/v1/chat/completions",
},
{
source: "/responses",
destination: "/api/v1/responses",
},
{
source: "/models",
destination: "/api/v1/models",
},
{
source: "/v1/v1/:path*",
destination: "/api/v1/:path*",
},
{
source: "/v1/v1",
destination: "/api/v1",
},
{
source: "/codex/:path*",
destination: "/api/v1/responses",
},
{
source: "/v1/:path*",
destination: "/api/v1/:path*",
},
{
source: "/v1",
destination: "/api/v1",
},
];
},
};
export default nextConfig;

8
open-sse/.npmignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
*.log
.DS_Store
test/
*.test.js
.env
.env.*

View File

@@ -0,0 +1,120 @@
// Default instructions for Codex models
// Source: CLIProxyAPI internal/misc/codex_instructions/
export const CODEX_DEFAULT_INSTRUCTIONS = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for \`network_access\` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for \`approval_policy\` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the \`shell\` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with \`danger-full-access\`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with \`approval_policy == on-request\`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the \`sandbox_permissions\` and \`justification\` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an \`rm\` or \`git reset\` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When \`sandbox_mode\` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the \`sandbox_permissions\` parameter with the value \`"require_escalated"\`
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as \`date\`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. \`git show\`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5`;

View File

@@ -0,0 +1,130 @@
import { loadProviderCredentials } from "./credentialLoader.js";
// Timeout for non-streaming fetch requests (ms). Prevents stalled connections.
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
// Provider configurations
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
// Use provider-credentials.json or env vars to override in production.
import { generateLegacyProviders } from "./providerRegistry.js";
export const PROVIDERS = generateLegacyProviders();
// Merge external credentials from data/provider-credentials.json (if present)
loadProviderCredentials(PROVIDERS);
// Claude system prompt
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
// Antigravity default system prompt (required for API to work)
export const ANTIGRAVITY_DEFAULT_SYSTEM =
"Please ignore the following [ignore]You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**[/ignore]";
// OAuth endpoints
export const OAUTH_ENDPOINTS = {
google: {
token: "https://oauth2.googleapis.com/token",
auth: "https://accounts.google.com/o/oauth2/auth",
},
openai: {
token: "https://auth.openai.com/oauth/token",
auth: "https://auth.openai.com/oauth/authorize",
},
anthropic: {
token: "https://console.anthropic.com/v1/oauth/token",
auth: "https://console.anthropic.com/v1/oauth/authorize",
},
qwen: {
token: "https://chat.qwen.ai/api/v1/oauth2/token", // From CLIProxyAPI
auth: "https://chat.qwen.ai/api/v1/oauth2/device/code", // From CLIProxyAPI
},
iflow: {
token: "https://iflow.cn/oauth/token",
auth: "https://iflow.cn/oauth",
},
github: {
token: "https://github.com/login/oauth/access_token",
auth: "https://github.com/login/oauth/authorize",
deviceCode: "https://github.com/login/device/code",
},
};
// Cache TTLs (seconds)
export const CACHE_TTL = {
userInfo: 300, // 5 minutes
modelAlias: 3600, // 1 hour
};
// Default max tokens
export const DEFAULT_MAX_TOKENS = 64000;
// Minimum max tokens for tool calling (to prevent truncated arguments)
export const DEFAULT_MIN_TOKENS = 32000;
// HTTP status codes
export const HTTP_STATUS = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
NOT_ACCEPTABLE: 406,
REQUEST_TIMEOUT: 408,
RATE_LIMITED: 429,
SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
};
// OpenAI-compatible error types mapping
export const ERROR_TYPES = {
[HTTP_STATUS.BAD_REQUEST]: { type: "invalid_request_error", code: "bad_request" },
[HTTP_STATUS.UNAUTHORIZED]: { type: "authentication_error", code: "invalid_api_key" },
[HTTP_STATUS.FORBIDDEN]: { type: "permission_error", code: "insufficient_quota" },
[HTTP_STATUS.NOT_FOUND]: { type: "invalid_request_error", code: "model_not_found" },
[HTTP_STATUS.RATE_LIMITED]: { type: "rate_limit_error", code: "rate_limit_exceeded" },
[HTTP_STATUS.SERVER_ERROR]: { type: "server_error", code: "internal_server_error" },
[HTTP_STATUS.BAD_GATEWAY]: { type: "server_error", code: "bad_gateway" },
[HTTP_STATUS.SERVICE_UNAVAILABLE]: { type: "server_error", code: "service_unavailable" },
[HTTP_STATUS.GATEWAY_TIMEOUT]: { type: "server_error", code: "gateway_timeout" },
};
// Default error messages per status code
export const DEFAULT_ERROR_MESSAGES = {
[HTTP_STATUS.BAD_REQUEST]: "Bad request",
[HTTP_STATUS.UNAUTHORIZED]: "Invalid API key provided",
[HTTP_STATUS.FORBIDDEN]: "You exceeded your current quota",
[HTTP_STATUS.NOT_FOUND]: "Model not found",
[HTTP_STATUS.RATE_LIMITED]: "Rate limit exceeded",
[HTTP_STATUS.SERVER_ERROR]: "Internal server error",
[HTTP_STATUS.BAD_GATEWAY]: "Bad gateway - upstream provider error",
[HTTP_STATUS.SERVICE_UNAVAILABLE]: "Service temporarily unavailable",
[HTTP_STATUS.GATEWAY_TIMEOUT]: "Gateway timeout",
};
// Exponential backoff config for rate limits (like CLIProxyAPI)
export const BACKOFF_CONFIG = {
base: 1000, // 1 second base
max: 2 * 60 * 1000, // 2 minutes max
maxLevel: 15, // Cap backoff level
};
// Error-based cooldown times (aligned with CLIProxyAPI)
export const COOLDOWN_MS = {
unauthorized: 2 * 60 * 1000, // 401 → 30 min
paymentRequired: 2 * 60 * 1000, // 402/403 → 30 min
notFound: 2 * 60 * 1000, // 404 → 2 minutes
transient: 30 * 1000, // 408/500/502/503/504 → 1 min
requestNotAllowed: 5 * 1000, // "Request not allowed" → 5 sec
// Legacy aliases for backward compatibility
rateLimit: 2 * 60 * 1000,
serviceUnavailable: 2 * 1000,
authExpired: 2 * 60 * 1000,
};
// Skip patterns - requests containing these texts will bypass provider
export const SKIP_PATTERNS = ["Please write a 5-10 word title for the following conversation:"];

View File

@@ -0,0 +1,102 @@
/**
* Credential Loader — Reads provider credentials from an external JSON file.
*
* Loads `provider-credentials.json` from the data directory and merges it
* over the hardcoded defaults in PROVIDERS. This keeps credentials out of
* source control while maintaining backwards compatibility (hardcoded values
* serve as defaults when the file is absent).
*
* Expected JSON structure:
* {
* "claude": { "clientId": "..." },
* "gemini": { "clientId": "...", "clientSecret": "..." },
* ...
* }
*/
import { readFileSync, existsSync } from "fs";
import { join } from "path";
// Fields that can be overridden per provider
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
// TTL-based cache — reloads credentials from disk at most once per minute
const CONFIG_TTL_MS = 60_000;
let lastLoadTime = 0;
let cachedProviders = null;
/**
* Resolve the path to provider-credentials.json
* Priority: DATA_DIR env → ./data (project root)
*/
function resolveCredentialsPath() {
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
return join(dataDir, "provider-credentials.json");
}
/**
* Load and merge external credentials into the PROVIDERS object.
* Uses TTL-based caching (60s) so credential file changes are picked up
* without requiring a server restart.
*
* @param {object} providers - The PROVIDERS object from constants.js
* @returns {object} The same PROVIDERS object (mutated in place)
*/
export function loadProviderCredentials(providers) {
// Return cached result if within TTL
if (cachedProviders && Date.now() - lastLoadTime < CONFIG_TTL_MS) {
return cachedProviders;
}
const credPath = resolveCredentialsPath();
if (!existsSync(credPath)) {
if (!cachedProviders) {
console.log("[CREDENTIALS] No external credentials file found, using defaults.");
}
cachedProviders = providers;
lastLoadTime = Date.now();
return providers;
}
try {
const raw = readFileSync(credPath, "utf-8");
const external = JSON.parse(raw);
let overrideCount = 0;
for (const [providerKey, creds] of Object.entries(external)) {
if (!providers[providerKey]) {
console.log(
`[CREDENTIALS] Warning: unknown provider "${providerKey}" in credentials file, skipping.`
);
continue;
}
if (!creds || typeof creds !== "object") {
console.log(
`[CREDENTIALS] Warning: provider "${providerKey}" value must be an object, got ${typeof creds}. Skipping.`
);
continue;
}
for (const field of CREDENTIAL_FIELDS) {
if (creds[field] !== undefined) {
providers[providerKey][field] = creds[field];
overrideCount++;
}
}
}
const isReload = cachedProviders !== null;
console.log(
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
);
} catch (err) {
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
}
cachedProviders = providers;
lastLoadTime = Date.now();
return providers;
}

View File

@@ -0,0 +1,8 @@
// Default signature for thinking mode when no signature from thinkingStore
export const DEFAULT_THINKING_CLAUDE_SIGNATURE =
"EpwGCkYIChgCKkCzVUuRrg7CcglSUWEef4rH6o35g9UYS8ZPe0/VomQTBsFx6sttYNj5l8GqgW6ejuHyYqpFToxIbZl0bw17l5dJEgzCnqDO0Z8fRlMrNgsaDLS1cnCjC53KBqE0CCIwAADQdo1eO+7qPAmo8J4WR3JPmr92S97kmvr5K1iPMiOpkZNj8mEXW8uzBoOJs/9ZKoMFiqHJ3UObwaJDqFOW70E9oCwDoc6jesaWVAEdN5vWfKMpIkjFJjECdjIdkxyJNJ8Ib8yXVal3qwE7uThoPRqSZDdHB5mmwPEjWE/90cSYCbtX2YsJki1265CabBb8/QEkODXg4kgRrL+c8e8rRXz/dr1RswvaPuzEdGKHRNi9UooNUeOK4/ebx1KkP9YZttyohN9GWqlts36kOoW0Cfie/ABDgF9g534BPth/sstxDM6d79QlRmh6NxizyTF74DXJI34u0M4tTRchqE5pAq85SgdJaa+dix1yJPMji8m6nZkwJbscJb9rdc2MKyKWjz8QL2+rTSSuZ2F1k1qSsW0xNcI7qLcI12Vncfn/VqY6YOIZy/saZBR0ezXvN6g+UYbuIdyVg7AyIFZt3nbrO7/kmOEb2VKzygwklHGEIJHfFgMpH3JSrAzbZIowVHOF7VaJ+KXRFDCFin7hHTOiOsdg+1ij1mML9Z/x/9CP4b7OUcaQm1llDZPSHc6rZMNL3DdB+fW5YfmNgKU35S+7AMtA10nVILzDAk1UV4T2K9Do09JlI6rjOs9UuULlIN2Z0eE8YTlANR6uQcw7lMcdfqYE8tke4rDKc2dDiaS5vVe45VewICNpdXGN11yw8QqH7p27CR1HtN30e0tHXOR3bIwWk/Yb6O5fTaKG6Ri8e5ZCPvdD9HqepVi188nM0iTjJqL58F3ni04ECIhcbyaQWnuTes1Kw4CMwiZDLQkk8Hgz7HkUOf1btQTF/0nhD7ry0n0hAEg2PaDM3V6TjOjf4hEldRmeqERcQF1PfgKb6ZM12rlIIfUqKACczWJSzTV158+47HX36o0cgux6nFlv/DE+sEiRVxgB";
export const DEFAULT_THINKING_GEMINI_SIGNATURE =
"EuwGCukGAXLI2nxwZIq54WWSoL/YN0P3TsDZ7zRnLi8g0S4aVr2HUGxvaHKySuY6HAVzcE0GPGjXrytLIldxthSvfxgUlJh6Qa9Z+Oj5QZBlYdg6HaJ6yuY5R7waE6rdwBsRf7Ft2j3DJ9rMi9qhWFqApewYtPhls3VHtuvND3l8Rm09+lbAXQs6KKWEWrxNLKTBkfpMgXhRERc/TQRMZu1twAablm6/Zk1tsYRvfWKLsNbeKF+CCojJdXJKvnR/8Ouuoa+Y2Ti20hcW7aZIIjZDFYPU//k6Ybmhg69J/imbFai2ckhfLaisqdDkdoIiBJScTOUvYqP6AE9d4MsydSC+UlhIMk4hoP76R8vUSCZRMkjOaDXstf/QoVZKbt94wyRZgAJ1G0BqI8L5ow86kLpA4wJEtxsRGymOE4bKUvApveBakYDNM9APkf+LbtbzWSseGjoZcSlycF9iN8Q2XNYKRrHbv3Lr5Y8JjdH/5y/6SHkNehTEZugaeGnSPSyCTWto1kQgHpxdWmhkLfJGNUGLmue7Mesj4TSms4J33mRpYVhNB/J333FCqIP0hr/E7BkkjEn7yZ4X7SQlh+xKPurapsnHRwiKmtsilmEFrnTE9iQr+pMr6M29qqFNv1tr5yumbaJw8JW9sB15tNsRv+dW6BjNanbsKz7HCgKUBc8tGy+7YuhXzAfViyRefcjK7eZW0Fbyt7AbybJTKz78W8NH7ye6LAwzOebXpeZ4D43fNIt8bKh26qgduSQv/7o+pAflkuqHZ99YWgHQ8h8OkZFi3eOiSYjsjhdZ/czWOdoPI/OnqIldzMPF5YlrKBLFX8VhRKVmqgsmWf5PHGulHhMkVlS+XG2UIseGy69ARa93D78Gsa+1n1kJr7EEB7Rh+27vUMxVYLdz1yMSvE5nalTAlg/ZeG8+XQ0cHuAI3KbQpHW2Q++RdXfm5JzD5WdJZUU+Zn8t8UUn85BH4RxZLeE0qJikgSsKoYVBc6YhiMjhPgkR95ReimY4Z0xCJdRo1gjexOFeODZMpQF6Yxnoic7IrdgsFA3iePTbFnPp3IAM1fAThWhXJUn3QInUOTd5o1qmTmn6REbL15g/JQNl+dqUoPkhleeb2V3kjqp1okmO3wMZbPknR3S1LZNmlS72/iBQUm+n2b/RCn4PjmM2";
export const DEFAULT_THINKING_TEXT = "...";

View File

@@ -0,0 +1,126 @@
/**
* Embedding Provider Registry
*
* Defines providers that support the /v1/embeddings endpoint.
* All providers use the OpenAI-compatible format.
*
* API keys are stored in the same provider credentials system,
* keyed by provider ID (e.g. "nebius", "openai").
*/
export const EMBEDDING_PROVIDERS = {
nebius: {
id: "nebius",
baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", dimensions: 4096 }],
},
openai: {
id: "openai",
baseUrl: "https://api.openai.com/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 },
{ id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 },
{ id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 },
],
},
mistral: {
id: "mistral",
baseUrl: "https://api.mistral.ai/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "mistral-embed", name: "Mistral Embed", dimensions: 1024 }],
},
together: {
id: "together",
baseUrl: "https://api.together.xyz/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", dimensions: 1024 },
{ id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", dimensions: 768 },
],
},
fireworks: {
id: "fireworks",
baseUrl: "https://api.fireworks.ai/inference/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "nomic-ai/nomic-embed-text-v1.5", name: "Nomic Embed Text v1.5", dimensions: 768 },
],
},
nvidia: {
id: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", dimensions: 1024 }],
},
};
/**
* Get embedding provider config by ID
*/
export function getEmbeddingProvider(providerId) {
return EMBEDDING_PROVIDERS[providerId] || null;
}
/**
* Parse embedding model string (format: "provider/model" or just "model")
* Returns { provider, model }
*/
export function parseEmbeddingModel(modelStr) {
if (!modelStr) return { provider: null, model: null };
// Check for "provider/model" format
const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
// Handle nested model IDs like "nebius/Qwen/Qwen3-Embedding-8B"
// Try each provider prefix
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
}
}
// Fallback: first segment is provider
const provider = modelStr.slice(0, slashIdx);
const model = modelStr.slice(slashIdx + 1);
return { provider, model };
}
// No provider prefix — search all providers for the model
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
return { provider: null, model: modelStr };
}
/**
* Get all embedding models as a flat list
*/
export function getAllEmbeddingModels() {
const models = [];
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
for (const model of config.models) {
models.push({
id: `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
dimensions: model.dimensions,
});
}
}
return models;
}

View File

@@ -0,0 +1,132 @@
/**
* Image Generation Provider Registry
*
* Defines providers that support the /v1/images/generations endpoint.
* Each provider has its own request format and endpoint.
*/
export const IMAGE_PROVIDERS = {
openai: {
id: "openai",
baseUrl: "https://api.openai.com/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai", // native OpenAI format
models: [
{ id: "gpt-image-1", name: "GPT Image 1" },
{ id: "dall-e-3", name: "DALL-E 3" },
{ id: "dall-e-2", name: "DALL-E 2" },
],
supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "256x256", "512x512"],
},
xai: {
id: "xai",
baseUrl: "https://api.x.ai/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [{ id: "grok-2-image-1212", name: "Grok 2 Image" }],
supportedSizes: ["1024x1024"],
},
together: {
id: "together",
baseUrl: "https://api.together.xyz/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [
{ id: "black-forest-labs/FLUX.1.1-pro", name: "FLUX 1.1 Pro" },
{ id: "black-forest-labs/FLUX.1-schnell-Free", name: "FLUX 1 Schnell (Free)" },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0" },
],
supportedSizes: ["1024x1024", "512x512"],
},
fireworks: {
id: "fireworks",
baseUrl: "https://api.fireworks.ai/inference/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [
{ id: "accounts/fireworks/models/flux-1-dev-fp8", name: "FLUX 1 Dev FP8" },
{ id: "accounts/fireworks/models/stable-diffusion-xl-1024-v1-0", name: "SDXL 1024 v1.0" },
],
supportedSizes: ["1024x1024", "512x512"],
},
antigravity: {
id: "antigravity",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
authType: "oauth",
authHeader: "bearer",
format: "gemini-image", // Special format: uses Gemini generateContent API
models: [{ id: "gemini-2.5-flash-preview-image-generation", name: "Nano Banana" }],
supportedSizes: ["1024x1024"],
},
nebius: {
id: "nebius",
baseUrl: "https://api.tokenfactory.nebius.com/v1/images/generations",
fallbackUrl: "https://api.studio.nebius.com/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [
{ id: "black-forest-labs/flux-schnell", name: "FLUX.1 Schnell" },
{ id: "black-forest-labs/flux-dev", name: "FLUX.1 Dev" },
],
supportedSizes: ["1024x1024", "512x512"],
},
};
/**
* Get image provider config by ID
*/
export function getImageProvider(providerId) {
return IMAGE_PROVIDERS[providerId] || null;
}
/**
* Parse image model string (format: "provider/model")
* Returns { provider, model }
*/
export function parseImageModel(modelStr) {
if (!modelStr) return { provider: null, model: null };
// Try each provider prefix
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
}
}
// No provider prefix — try to find the model in any provider
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
return { provider: null, model: modelStr };
}
/**
* Get all image models as a flat list
*/
export function getAllImageModels() {
const models = [];
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
for (const model of config.models) {
models.push({
id: `${providerId}/${model.id}`,
name: model.name,
provider: providerId,
supportedSizes: config.supportedSizes,
});
}
}
return models;
}

View File

@@ -0,0 +1,28 @@
export const ollamaModels = {
models: [
{
name: "llama3.2",
modified_at: "2025-12-26T00:00:00Z",
size: 2000000000,
digest: "abc123def456",
details: {
format: "gguf",
family: "llama",
parameter_size: "3B",
quantization_level: "Q4_K_M",
},
},
{
name: "qwen2.5",
modified_at: "2025-12-26T00:00:00Z",
size: 4000000000,
digest: "def456abc123",
details: {
format: "gguf",
family: "qwen",
parameter_size: "7B",
quantization_level: "Q4_K_M",
},
},
],
};

View File

@@ -0,0 +1,43 @@
import { generateModels, generateAliasMap } from "./providerRegistry.js";
// Provider models - Generated from providerRegistry.js (single source of truth)
export const PROVIDER_MODELS = generateModels();
// Provider ID to alias mapping - Generated from providerRegistry.js
export const PROVIDER_ID_TO_ALIAS = generateAliasMap();
// Helper functions
export function getProviderModels(aliasOrId) {
return PROVIDER_MODELS[aliasOrId] || [];
}
export function getDefaultModel(aliasOrId) {
const models = PROVIDER_MODELS[aliasOrId];
return models?.[0]?.id || null;
}
export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) {
if (passthroughProviders.has(aliasOrId)) return true;
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return false;
return models.some((m) => m.id === modelId);
}
export function findModelName(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return modelId;
const found = models.find((m) => m.id === modelId);
return found?.name || modelId;
}
export function getModelTargetFormat(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
const found = models.find((m) => m.id === modelId);
return found?.targetFormat || null;
}
export function getModelsByProviderId(providerId) {
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
return PROVIDER_MODELS[alias] || [];
}

View File

@@ -0,0 +1,831 @@
/**
* Provider Registry — Single source of truth for all provider configuration.
*
* Adding a new provider? Just add an entry here. Everything else
* (PROVIDERS, PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, executor lookup)
* is auto-generated from this registry.
*/
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY = {
// ─── OAuth Providers ───────────────────────────────────────────────────
claude: {
id: "claude",
alias: "cc",
format: "claude",
executor: "default",
baseUrl: "https://api.anthropic.com/v1/messages",
urlSuffix: "?beta=true",
authType: "oauth",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta":
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/1.0.83 (external, cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Package-Version": "0.55.1",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": "arm64",
"X-Stainless-Os": "MacOS",
"X-Stainless-Timeout": "60",
},
oauth: {
clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID",
clientIdDefault: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
},
models: [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
],
},
gemini: {
id: "gemini",
alias: "gemini",
format: "gemini",
executor: "default",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
urlBuilder: (base, model, stream) => {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}/${model}:${action}`;
},
authType: "apikey",
authHeader: "x-goog-api-key",
oauth: {
clientIdEnv: "GEMINI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
},
models: [
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
],
},
"gemini-cli": {
id: "gemini-cli",
alias: "gc",
format: "gemini-cli",
executor: "gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com/v1internal",
urlBuilder: (base, model, stream) => {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}:${action}`;
},
authType: "oauth",
authHeader: "bearer",
oauth: {
clientIdEnv: "GEMINI_CLI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecretEnv: "GEMINI_CLI_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
},
models: [
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
],
},
codex: {
id: "codex",
alias: "cx",
format: "openai-responses",
executor: "codex",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
authType: "oauth",
authHeader: "bearer",
headers: {
Version: "0.92.0",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.92.0 (Windows 10.0.26100; x64)",
},
oauth: {
clientIdEnv: "CODEX_OAUTH_CLIENT_ID",
clientIdDefault: "app_EMoamEEZ73f0CkXaXp7hrann",
clientSecretEnv: "CODEX_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
tokenUrl: "https://auth.openai.com/oauth/token",
},
models: [
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
{ id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini" },
{ id: "gpt-5.1-codex-mini-high", name: "GPT 5.1 Codex Mini (High)" },
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
{ id: "gpt-5.2", name: "GPT 5.2" },
{ id: "gpt-5.1-codex-max", name: "GPT 5.1 Codex Max" },
{ id: "gpt-5.1-codex", name: "GPT 5.1 Codex" },
{ id: "gpt-5.1", name: "GPT 5.1" },
{ id: "gpt-5-codex", name: "GPT 5 Codex" },
{ id: "gpt-5-codex-mini", name: "GPT 5 Codex Mini" },
],
},
qwen: {
id: "qwen",
alias: "qw",
format: "openai",
executor: "default",
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
},
oauth: {
clientIdEnv: "QWEN_OAUTH_CLIENT_ID",
clientIdDefault: "f0304373b74a44d2b584a3fb70ca9e56",
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
},
models: [
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
],
},
iflow: {
id: "iflow",
alias: "if",
format: "openai",
executor: "default",
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "iFlow-Cli",
},
oauth: {
clientIdEnv: "IFLOW_OAUTH_CLIENT_ID",
clientIdDefault: "10009311001",
clientSecretEnv: "IFLOW_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
tokenUrl: "https://iflow.cn/oauth/token",
authUrl: "https://iflow.cn/oauth",
},
models: [
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "kimi-k2", name: "Kimi K2" },
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek-v3.2-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-v3.2-reasoner", name: "DeepSeek V3.2 Reasoner" },
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
},
antigravity: {
id: "antigravity",
alias: "ag",
format: "antigravity",
executor: "antigravity",
baseUrls: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"],
urlBuilder: (base, model, stream) => {
const path = stream
? "/v1internal:streamGenerateContent?alt=sse"
: "/v1internal:generateContent";
return `${base}${path}`;
},
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "antigravity/1.104.0 darwin/arm64",
},
oauth: {
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
clientIdDefault: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
},
models: [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking" },
{ id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High" },
{ id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
],
},
github: {
id: "github",
alias: "gh",
format: "openai",
executor: "github",
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
authType: "oauth",
authHeader: "bearer",
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.107.1",
"editor-plugin-version": "copilot-chat/0.26.7",
"user-agent": "GitHubCopilotChat/0.26.7",
"openai-intent": "conversation-panel",
"x-github-api-version": "2025-04-01",
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
Accept: "application/json",
"Content-Type": "application/json",
},
models: [
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4", name: "GPT-4" },
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
{ id: "gpt-5", name: "GPT-5" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "gpt-5-codex", name: "GPT-5 Codex", targetFormat: "openai-responses" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex", name: "GPT-5.1 Codex", targetFormat: "openai-responses" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", targetFormat: "openai-responses" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex", targetFormat: "openai-responses" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-opus-4.1", name: "Claude Opus 4.1" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Full ID)" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
],
},
kiro: {
id: "kiro",
alias: "kr",
format: "kiro",
executor: "kiro",
baseUrl: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
authType: "oauth",
authHeader: "bearer",
headers: {
"Content-Type": "application/json",
Accept: "application/vnd.amazon.eventstream",
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
},
oauth: {
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev",
},
models: [
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
],
},
cursor: {
id: "cursor",
alias: "cu",
format: "cursor",
executor: "cursor",
baseUrl: "https://api2.cursor.sh",
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
authType: "oauth",
authHeader: "bearer",
headers: {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
},
clientVersion: "1.1.3",
models: [
{ id: "default", name: "Auto (Server Picks)" },
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
{ id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" },
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
],
},
// ─── API Key Providers ─────────────────────────────────────────────────
openai: {
id: "openai",
alias: "openai",
format: "openai",
executor: "default",
baseUrl: "https://api.openai.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
{ id: "o1", name: "O1" },
{ id: "o1-mini", name: "O1 Mini" },
],
},
anthropic: {
id: "anthropic",
alias: "anthropic",
format: "claude",
executor: "default",
baseUrl: "https://api.anthropic.com/v1/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
},
models: [
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
],
},
openrouter: {
id: "openrouter",
alias: "openrouter",
format: "openai",
executor: "default",
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
models: [{ id: "auto", name: "Auto (Best Available)" }],
},
glm: {
id: "glm",
alias: "glm",
format: "claude",
executor: "default",
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
{ id: "glm-4.6", name: "GLM 4.6" },
{ id: "glm-4.5v", name: "GLM 4.5V (Vision)" },
{ id: "glm-4.5", name: "GLM 4.5" },
{ id: "glm-4.5-air", name: "GLM 4.5 Air" },
{ id: "glm-4-32b", name: "GLM 4 32B" },
],
},
kimi: {
id: "kimi",
alias: "kimi",
format: "openai",
executor: "default",
baseUrl: "https://api.moonshot.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
},
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",
format: "claude",
executor: "default",
baseUrl: "https://api.kimi.com/coding/v1/messages",
urlSuffix: "?beta=true",
authType: "oauth",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
oauth: {
clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID",
clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098",
tokenUrl: "https://auth.kimi.com/api/oauth/token",
refreshUrl: "https://auth.kimi.com/api/oauth/token",
authUrl: "https://auth.kimi.com/api/oauth/device_authorization",
},
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
},
kilocode: {
id: "kilocode",
alias: "kc",
format: "openrouter",
executor: "openrouter",
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
authType: "oauth",
authHeader: "Authorization",
authPrefix: "Bearer ",
oauth: {
initiateUrl: "https://api.kilo.ai/api/device-auth/codes",
pollUrlBase: "https://api.kilo.ai/api/device-auth/codes",
},
models: [
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
{ id: "openai/o3", name: "o3" },
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
],
passthroughModels: true,
},
cline: {
id: "cline",
alias: "cl",
format: "openai",
executor: "openai",
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
authType: "oauth",
authHeader: "Authorization",
authPrefix: "Bearer ",
oauth: {
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
authUrl: "https://api.cline.bot/api/v1/auth/authorize",
},
extraHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
models: [
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
{ id: "openai/o3", name: "o3" },
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
],
passthroughModels: true,
},
minimax: {
id: "minimax",
alias: "minimax",
format: "claude",
executor: "default",
baseUrl: "https://api.minimax.io/anthropic/v1/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
},
"minimax-cn": {
id: "minimax-cn",
alias: "minimax-cn", // unique alias (was colliding with minimax)
format: "claude",
executor: "default",
baseUrl: "https://api.minimaxi.com/anthropic/v1/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
],
},
deepseek: {
id: "deepseek",
alias: "ds",
format: "openai",
executor: "default",
baseUrl: "https://api.deepseek.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
],
},
groq: {
id: "groq",
alias: "groq",
format: "openai",
executor: "default",
baseUrl: "https://api.groq.com/openai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" },
{ id: "meta-llama/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
{ id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" },
],
},
xai: {
id: "xai",
alias: "xai",
format: "openai",
executor: "default",
baseUrl: "https://api.x.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "grok-4", name: "Grok 4" },
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
{ id: "grok-3", name: "Grok 3" },
],
},
mistral: {
id: "mistral",
alias: "mistral",
format: "openai",
executor: "default",
baseUrl: "https://api.mistral.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "mistral-large-latest", name: "Mistral Large 3" },
{ id: "codestral-latest", name: "Codestral" },
{ id: "mistral-medium-latest", name: "Mistral Medium 3" },
],
},
perplexity: {
id: "perplexity",
alias: "pplx",
format: "openai",
executor: "default",
baseUrl: "https://api.perplexity.ai/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "sonar-pro", name: "Sonar Pro" },
{ id: "sonar", name: "Sonar" },
],
},
together: {
id: "together",
alias: "together",
format: "openai",
executor: "default",
baseUrl: "https://api.together.xyz/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", name: "Llama 3.3 70B Turbo" },
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" },
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
],
},
fireworks: {
id: "fireworks",
alias: "fireworks",
format: "openai",
executor: "default",
baseUrl: "https://api.fireworks.ai/inference/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" },
{ id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "accounts/fireworks/models/qwen3-235b-a22b", name: "Qwen3 235B" },
],
},
cerebras: {
id: "cerebras",
alias: "cerebras",
format: "openai",
executor: "default",
baseUrl: "https://api.cerebras.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "gpt-oss-120b", name: "GPT OSS 120B" },
{ id: "zai-glm-4.7", name: "ZAI GLM 4.7" },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B" },
{ id: "llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout" },
{ id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B" },
{ id: "qwen-3-32b", name: "Qwen3 32B" },
],
},
cohere: {
id: "cohere",
alias: "cohere",
format: "openai",
executor: "default",
baseUrl: "https://api.cohere.com/v2/chat",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "command-r-plus-08-2024", name: "Command R+ (Aug 2024)" },
{ id: "command-r-08-2024", name: "Command R (Aug 2024)" },
{ id: "command-a-03-2025", name: "Command A (Mar 2025)" },
],
},
nvidia: {
id: "nvidia",
alias: "nvidia",
format: "openai",
executor: "default",
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
],
},
nebius: {
id: "nebius",
alias: "nebius",
format: "openai",
executor: "default",
baseUrl: "https://api.tokenfactory.nebius.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }],
},
siliconflow: {
id: "siliconflow",
alias: "siliconflow",
format: "openai",
executor: "default",
baseUrl: "https://api.siliconflow.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" },
{ id: "deepseek-ai/DeepSeek-V3.1", name: "DeepSeek V3.1" },
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "Qwen/Qwen3-235B-A22B-Instruct-2507", name: "Qwen3 235B" },
{ id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", name: "Qwen3 Coder 480B" },
{ id: "Qwen/Qwen3-32B", name: "Qwen3 32B" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
{ id: "zai-org/GLM-4.7", name: "GLM 4.7" },
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B" },
{ id: "baidu/ERNIE-4.5-300B-A47B", name: "ERNIE 4.5 300B" },
],
},
};
// ── Generator Functions ───────────────────────────────────────────────────
/** Generate legacy PROVIDERS object shape for constants.js backward compatibility */
export function generateLegacyProviders() {
const providers = {};
for (const [id, entry] of Object.entries(REGISTRY)) {
const p = { format: entry.format };
// URL(s)
if (entry.baseUrls) {
p.baseUrls = entry.baseUrls;
} else if (entry.baseUrl) {
p.baseUrl = entry.baseUrl;
}
if (entry.responsesBaseUrl) {
p.responsesBaseUrl = entry.responsesBaseUrl;
}
// Headers
const mergedHeaders = {
...(entry.headers || {}),
...(entry.extraHeaders || {}),
};
if (Object.keys(mergedHeaders).length > 0) {
p.headers = mergedHeaders;
}
// OAuth
if (entry.oauth) {
if (entry.oauth.clientIdEnv) {
p.clientId = process.env[entry.oauth.clientIdEnv] || entry.oauth.clientIdDefault;
}
if (entry.oauth.clientSecretEnv) {
p.clientSecret =
process.env[entry.oauth.clientSecretEnv] || entry.oauth.clientSecretDefault;
}
if (entry.oauth.tokenUrl) p.tokenUrl = entry.oauth.tokenUrl;
if (entry.oauth.refreshUrl) p.refreshUrl = entry.oauth.refreshUrl;
if (entry.oauth.authUrl) p.authUrl = entry.oauth.authUrl;
}
// Cursor-specific
if (entry.chatPath) p.chatPath = entry.chatPath;
if (entry.clientVersion) p.clientVersion = entry.clientVersion;
providers[id] = p;
}
return providers;
}
/** Generate PROVIDER_MODELS map (alias → model list) */
export function generateModels() {
const models = {};
for (const entry of Object.values(REGISTRY)) {
if (entry.models && entry.models.length > 0) {
const key = entry.alias || entry.id;
// If alias already exists, don't overwrite (first wins)
if (!models[key]) {
models[key] = entry.models;
}
}
}
return models;
}
/** Generate PROVIDER_ID_TO_ALIAS map */
export function generateAliasMap() {
const map = {};
for (const entry of Object.values(REGISTRY)) {
map[entry.id] = entry.alias || entry.id;
}
return map;
}
// ── Registry Lookup Helpers ───────────────────────────────────────────────
const _byAlias = new Map();
for (const entry of Object.values(REGISTRY)) {
if (entry.alias && entry.alias !== entry.id) {
_byAlias.set(entry.alias, entry);
}
}
/** Get registry entry by provider ID or alias */
export function getRegistryEntry(provider) {
return REGISTRY[provider] || _byAlias.get(provider) || null;
}
/** Get all registered provider IDs */
export function getRegisteredProviders() {
return Object.keys(REGISTRY);
}

View File

@@ -0,0 +1,273 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.js";
const MAX_RETRY_AFTER_MS = 10000;
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
}
buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${baseUrl}/v1internal:${action}`;
}
buildHeaders(credentials, stream = true) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64",
"X-OmniRoute-Source": "omniroute",
...(stream && { Accept: "text/event-stream" }),
};
}
transformRequest(model, body, stream, credentials) {
const projectId = credentials?.projectId || this.generateProjectId();
// Fix contents for Claude models via Antigravity
const normalizedContents =
body.request?.contents?.map((c) => {
let role = c.role;
// functionResponse must be role "user" for Claude models
if (c.parts?.some((p) => p.functionResponse)) {
role = "user";
}
// Strip thought parts (no valid signature -> provider rejects).
// Also drop entries that become empty after filtering, which can trigger
// 400 invalid argument on Gemini 3 Flash through Antigravity.
const parts = c.parts?.filter((p) => !p.thought && !p.thoughtSignature) || [];
return { ...c, role, parts };
}) || [];
const contents = normalizedContents.filter((c) =>
Array.isArray(c.parts) ? c.parts.length > 0 : true
);
const transformedRequest = {
...body.request,
...(contents.length > 0 && { contents }),
sessionId: body.request?.sessionId || this.generateSessionId(),
safetySettings: undefined,
toolConfig:
body.request?.tools?.length > 0
? { functionCallingConfig: { mode: "VALIDATED" } }
: body.request?.toolConfig,
};
return {
...body,
project: projectId,
model: model,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
request: transformedRequest,
};
}
async refreshCredentials(credentials, log) {
if (!credentials.refreshToken) return null;
try {
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "Antigravity refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || credentials.refreshToken,
expiresIn: tokens.expires_in,
projectId: credentials.projectId,
};
} catch (error) {
log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`);
return null;
}
}
generateProjectId() {
const adj = ["useful", "bright", "swift", "calm", "bold"][Math.floor(Math.random() * 5)];
const noun = ["fuze", "wave", "spark", "flow", "core"][Math.floor(Math.random() * 5)];
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
generateSessionId() {
return `-${Math.floor(Math.random() * 9_000_000_000_000_000_000)}`;
}
parseRetryHeaders(headers) {
if (!headers?.get) return null;
const retryAfter = headers.get("retry-after");
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
const diff = date.getTime() - Date.now();
return diff > 0 ? diff : null;
}
}
const resetAfter = headers.get("x-ratelimit-reset-after");
if (resetAfter) {
const seconds = parseInt(resetAfter, 10);
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
}
const resetTimestamp = headers.get("x-ratelimit-reset");
if (resetTimestamp) {
const ts = parseInt(resetTimestamp, 10) * 1000;
const diff = ts - Date.now();
return diff > 0 ? diff : null;
}
return null;
}
// Parse retry time from Antigravity error message body
// Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s"
parseRetryFromErrorMessage(errorMessage) {
if (!errorMessage || typeof errorMessage !== "string") return null;
const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; // hours
if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes
if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds
return totalMs > 0 ? totalMs : null;
}
async execute({ model, body, stream, credentials, signal, log }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
const MAX_AUTO_RETRIES = 3;
const retryAttemptsByUrl = {}; // Track retry attempts per URL
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
// Initialize retry counter for this URL
if (!retryAttemptsByUrl[urlIndex]) {
retryAttemptsByUrl[urlIndex] = 0;
}
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal,
});
if (
response.status === HTTP_STATUS.RATE_LIMITED ||
response.status === HTTP_STATUS.SERVICE_UNAVAILABLE
) {
// Try to get retry time from headers first
let retryMs = this.parseRetryHeaders(response.headers);
// If no retry time in headers, try to parse from error message body
if (!retryMs) {
try {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
} catch (e) {
// Ignore parse errors, will fall back to exponential backoff
}
}
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS) {
log?.debug?.(
"RETRY",
`${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting...`
);
await new Promise((resolve) => setTimeout(resolve, retryMs));
urlIndex--;
continue;
}
// Auto retry only for 429 when retryMs is 0 or undefined
if (
response.status === HTTP_STATUS.RATE_LIMITED &&
(!retryMs || retryMs === 0) &&
retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES
) {
retryAttemptsByUrl[urlIndex]++;
// Exponential backoff: 2s, 4s, 8s...
const backoffMs = Math.min(
1000 * 2 ** retryAttemptsByUrl[urlIndex],
MAX_RETRY_AFTER_MS
);
log?.debug?.(
"RETRY",
`429 auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s`
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
urlIndex--;
continue;
}
log?.debug?.(
"RETRY",
`${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : "missing"}, trying fallback`
);
lastStatus = response.status;
if (urlIndex + 1 < fallbackCount) {
continue;
}
}
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
continue;
}
return { response, url, headers, transformedBody };
} catch (error) {
lastError = error;
if (urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;
}
throw error;
}
}
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
}
}
export default AntigravityExecutor;

133
open-sse/executors/base.js Normal file
View File

@@ -0,0 +1,133 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.js";
/**
* BaseExecutor - Base class for provider executors.
* Implements the Strategy pattern: subclasses override specific methods
* (buildUrl, buildHeaders, transformRequest, etc.) for each provider.
*/
export class BaseExecutor {
constructor(provider, config) {
this.provider = provider;
this.config = config;
}
getProvider() {
return this.provider;
}
getBaseUrls() {
return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []);
}
getFallbackCount() {
return this.getBaseUrls().length || 1;
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
const baseUrls = this.getBaseUrls();
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
}
buildHeaders(credentials, stream = true) {
const headers = {
"Content-Type": "application/json",
...this.config.headers,
};
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} else if (credentials.apiKey) {
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
}
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
// Override in subclass for provider-specific transformations
transformRequest(model, body, stream, credentials) {
return body;
}
shouldRetry(status, urlIndex) {
return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount();
}
// Override in subclass for provider-specific refresh
async refreshCredentials(credentials, log) {
return null;
}
needsRefresh(credentials) {
if (!credentials.expiresAt) return false;
const expiresAtMs = new Date(credentials.expiresAt).getTime();
return expiresAtMs - Date.now() < 5 * 60 * 1000;
}
parseError(response, bodyText) {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
// For non-streaming requests, apply a fetch timeout to prevent stalled connections.
// Streaming requests skip the timeout — they use stream idle detection instead.
const timeoutSignal = !stream ? AbortSignal.timeout(FETCH_TIMEOUT_MS) : null;
const combinedSignal =
signal && timeoutSignal
? AbortSignal.any([signal, timeoutSignal])
: signal || timeoutSignal;
const fetchOptions = {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
};
if (combinedSignal) fetchOptions.signal = combinedSignal;
const response = await fetch(url, fetchOptions);
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
continue;
}
return { response, url, headers, transformedBody };
} catch (error) {
// Distinguish timeout errors from other abort errors
if (error.name === "TimeoutError") {
log?.warn?.("TIMEOUT", `Fetch timeout after ${FETCH_TIMEOUT_MS}ms on ${url}`);
}
lastError = error;
if (urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;
}
throw error;
}
}
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
}
}
export default BaseExecutor;

View File

@@ -0,0 +1,75 @@
import { BaseExecutor } from "./base.js";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
import { PROVIDERS } from "../config/constants.js";
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
*/
export class CodexExecutor extends BaseExecutor {
constructor() {
super("codex", PROVIDERS.codex);
}
/**
* Codex Responses endpoint is SSE-first.
* Always request event-stream from upstream, even when client requested stream=false.
*/
buildHeaders(credentials, stream = true) {
return super.buildHeaders(credentials, true);
}
/**
* Transform request before sending - inject default instructions if missing
*/
transformRequest(model, body, stream, credentials) {
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
body.stream = true;
// If no instructions provided, inject default Codex instructions
if (!body.instructions || body.instructions.trim() === "") {
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
}
// Ensure store is false (Codex requirement)
body.store = false;
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
let modelEffort = null;
for (const level of effortLevels) {
if (model.endsWith(`-${level}`)) {
modelEffort = level;
// Strip suffix from model name for actual API call
body.model = body.model.replace(`-${level}`, "");
break;
}
}
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || "medium";
body.reasoning = { effort };
}
delete body.reasoning_effort;
// Remove unsupported parameters for Codex API
delete body.temperature;
delete body.top_p;
delete body.frequency_penalty;
delete body.presence_penalty;
delete body.logprobs;
delete body.top_logprobs;
delete body.n;
delete body.seed;
delete body.max_tokens;
delete body.user; // Cursor sends this but Codex doesn't support it
delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it
delete body.metadata; // Cursor sends this but Codex doesn't support it
delete body.stream_options; // Cursor sends this but Codex doesn't support it
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
return body;
}
}

View File

@@ -0,0 +1,739 @@
/**
* CursorExecutor — Handles communication with the Cursor IDE API.
*
* This executor is the most complex due to Cursor's non-standard protocol:
*
* SECTION 1: Authentication (generateChecksum)
* - SHA-256 based checksum using machine ID and timestamp
* - WorkOS token refresh for session management
*
* SECTION 2: Request Encoding (transformRequest, buildHeaders)
* - ConnectRPC Protobuf binary encoding via cursorProtobuf.js
* - Chat body construction with model routing
*
* SECTION 3: Response Parsing (executeStream, parseEvent*)
* - Binary EventStream → SSE text conversion
* - Gzip decompression of response frames
* - HTTP/2 support with h2 fallback to fetch
*
* @see cursorProtobuf.js for Protobuf encoding/decoding utilities
*/
import { BaseExecutor } from "./base.js";
import { PROVIDERS, HTTP_STATUS } from "../config/constants.js";
import {
generateCursorBody,
parseConnectRPCFrame,
extractTextFromResponse,
} from "../utils/cursorProtobuf.js";
import { estimateUsage } from "../utils/usageTracking.js";
import { FORMATS } from "../translator/formats.js";
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
import zlib from "zlib";
// Detect cloud environment
const isCloudEnv = () => {
if (typeof caches !== "undefined" && typeof caches === "object") return true;
if (typeof EdgeRuntime !== "undefined") return true;
return false;
};
// Lazy import http2 (only in Node.js environment)
let http2 = null;
if (!isCloudEnv()) {
try {
http2 = await import("http2");
} catch {
// http2 not available
}
}
// --- SECTION 1: Authentication Constants ---
const COMPRESS_FLAG = {
NONE: 0x00,
GZIP: 0x01,
GZIP_ALT: 0x02,
GZIP_BOTH: 0x03,
};
function decompressPayload(payload, flags) {
// Check if payload is JSON error (starts with {"error")
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString("utf-8");
if (text.startsWith('{"error"')) {
console.log(`[DECOMPRESS] Detected JSON error, skipping decompression`);
return payload;
}
} catch {}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
console.log(
`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`
);
console.log(`[DECOMPRESS ERROR] First 50 bytes (hex):`, payload.slice(0, 50).toString("hex"));
console.log(
`[DECOMPRESS ERROR] First 50 bytes (utf8):`,
payload
.slice(0, 50)
.toString("utf8")
.replace(/[^\x20-\x7E]/g, ".")
);
// Try to use payload as-is if decompression fails
return payload;
}
}
return payload;
}
function createErrorResponse(jsonError) {
const errorMsg =
jsonError?.error?.details?.[0]?.debug?.details?.title ||
jsonError?.error?.details?.[0]?.debug?.details?.detail ||
jsonError?.error?.message ||
"API Error";
const isRateLimit = jsonError?.error?.code === "resource_exhausted";
return new Response(
JSON.stringify({
error: {
message: errorMsg,
type: isRateLimit ? "rate_limit_error" : "api_error",
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown",
},
}),
{
status: isRateLimit ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST,
headers: { "Content-Type": "application/json" },
}
);
}
export class CursorExecutor extends BaseExecutor {
constructor() {
super("cursor", PROVIDERS.cursor);
}
buildUrl() {
return `${this.config.baseUrl}${this.config.chatPath}`;
}
// Jyh cipher checksum for Cursor API authentication
generateChecksum(machineId) {
const timestamp = Math.floor(Date.now() / 1000000);
const byteArray = new Uint8Array([
(timestamp >> 40) & 0xff,
(timestamp >> 32) & 0xff,
(timestamp >> 24) & 0xff,
(timestamp >> 16) & 0xff,
(timestamp >> 8) & 0xff,
timestamp & 0xff,
]);
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff;
t = byteArray[i];
}
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let encoded = "";
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
}
buildHeaders(credentials) {
const accessToken = credentials.accessToken;
const machineId = credentials.providerSpecificData?.machineId;
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
if (!machineId) {
throw new Error("Machine ID is required for Cursor API");
}
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
return {
authorization: `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"content-type": "application/connect+proto",
"user-agent": "connect-es/1.6.1",
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"),
"x-cursor-checksum": this.generateChecksum(machineId),
"x-cursor-client-version": "2.3.41",
"x-cursor-client-type": "ide",
"x-cursor-client-os":
process.platform === "win32"
? "windows"
: process.platform === "darwin"
? "macos"
: "linux",
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
"x-cursor-client-device-type": "desktop",
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",
"x-request-id": crypto.randomUUID(),
"x-session-id": uuidv5(cleanToken, uuidv5.DNS),
};
}
transformRequest(model, body, stream, credentials) {
// Call translator to convert OpenAI format to Cursor format
const translatedBody = buildCursorRequest(model, body, stream, credentials);
const messages = translatedBody.messages || [];
const tools = translatedBody.tools || body.tools || [];
const reasoningEffort = body.reasoning_effort || null;
return generateCursorBody(messages, model, tools, reasoningEffort);
}
async makeFetchRequest(url, headers, body, signal) {
const response = await fetch(url, {
method: "POST",
headers,
body,
signal,
});
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: Buffer.from(await response.arrayBuffer()),
};
}
makeHttp2Request(url, headers, body, signal) {
if (!http2) {
throw new Error("http2 module not available");
}
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunks = [];
let responseHeaders = {};
client.on("error", reject);
const req = client.request({
":method": "POST",
":path": urlObj.pathname,
":authority": urlObj.host,
":scheme": "https",
...headers,
});
req.on("response", (hdrs) => {
responseHeaders = hdrs;
});
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
client.close();
resolve({
status: responseHeaders[":status"],
headers: responseHeaders,
body: Buffer.concat(chunks),
});
});
req.on("error", (err) => {
client.close();
reject(err);
});
if (signal) {
signal.addEventListener("abort", () => {
req.close();
client.close();
reject(new Error("Request aborted"));
});
}
req.write(body);
req.end();
});
}
async execute({ model, body, stream, credentials, signal, log }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
const response = http2
? await this.makeHttp2Request(url, headers, transformedBody, signal)
: await this.makeFetchRequest(url, headers, transformedBody, signal);
if (response.status !== 200) {
const errorText = response.body?.toString() || "Unknown error";
const errorResponse = new Response(
JSON.stringify({
error: {
message: `[${response.status}]: ${errorText}`,
type: "invalid_request_error",
code: "",
},
}),
{
status: response.status,
headers: { "Content-Type": "application/json" },
}
);
return { response: errorResponse, url, headers, transformedBody: body };
}
const transformedResponse =
stream !== false
? this.transformProtobufToSSE(response.body, model, body)
: this.transformProtobufToJSON(response.body, model, body);
return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) {
const errorResponse = new Response(
JSON.stringify({
error: {
message: error.message,
type: "connection_error",
code: "",
},
}),
{
status: HTTP_STATUS.SERVER_ERROR,
headers: { "Content-Type": "application/json" },
}
);
return { response: errorResponse, url, headers, transformedBody: body };
}
}
transformProtobufToJSON(buffer, model, body) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let offset = 0;
let totalContent = "";
const toolCalls = [];
const toolCallsMap = new Map(); // Track streaming tool calls by ID
let frameCount = 0;
console.log(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) {
if (offset + 5 > buffer.length) {
console.log(
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
console.log(
`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
console.log(
`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
frameCount++;
payload = decompressPayload(payload, flags);
if (!payload) {
console.log(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text));
}
} catch {}
const result = extractTextFromResponse(new Uint8Array(payload));
console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result);
if (result.error) {
return new Response(
JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited",
},
}),
{
status: HTTP_STATUS.RATE_LIMITED,
headers: { "Content-Type": "application/json" },
}
);
}
if (result.toolCall) {
const tc = result.toolCall;
if (toolCallsMap.has(tc.id)) {
// Accumulate arguments for existing tool call
const existing = toolCallsMap.get(tc.id);
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
} else {
// New tool call
toolCallsMap.set(tc.id, { ...tc });
}
// Push to final array when isLast is true
if (tc.isLast) {
const finalToolCall = toolCallsMap.get(tc.id);
toolCalls.push({
id: finalToolCall.id,
type: finalToolCall.type,
function: {
name: finalToolCall.function.name,
arguments: finalToolCall.function.arguments,
},
});
}
}
if (result.text) totalContent += result.text;
}
console.log(
`[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}`
);
// Finalize all remaining tool calls in map (in case stream ended without isLast=true)
for (const [id, tc] of toolCallsMap.entries()) {
// Check if already in final array
if (!toolCalls.find((t) => t.id === id)) {
console.log(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
toolCalls.push({
id: tc.id,
type: tc.type,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
});
}
}
console.log(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
const message = {
role: "assistant",
content: totalContent || null,
};
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
const completion = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message,
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop",
},
],
usage,
};
return new Response(JSON.stringify(completion), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
transformProtobufToSSE(buffer, model, body) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const chunks = [];
let offset = 0;
let totalContent = "";
const toolCalls = [];
const toolCallsMap = new Map(); // Track streaming tool calls by ID
let frameCount = 0;
console.log(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) {
if (offset + 5 > buffer.length) {
console.log(
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
console.log(
`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
console.log(
`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
frameCount++;
payload = decompressPayload(payload, flags);
if (!payload) {
console.log(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text));
}
} catch {}
const result = extractTextFromResponse(new Uint8Array(payload));
console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
if (result.error) {
return new Response(
JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited",
},
}),
{
status: HTTP_STATUS.RATE_LIMITED,
headers: { "Content-Type": "application/json" },
}
);
}
if (result.toolCall) {
const tc = result.toolCall;
if (chunks.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],
})}\n\n`
);
}
if (toolCallsMap.has(tc.id)) {
// Accumulate arguments for existing tool call
const existing = toolCallsMap.get(tc.id);
const oldArgsLen = existing.function.arguments.length;
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
// Stream the delta arguments
if (tc.function.arguments) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: existing.index,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
}
} else {
// New tool call - assign index and add to map
const toolCallIndex = toolCalls.length;
toolCalls.push({ ...tc, index: toolCallIndex });
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
// Stream initial tool call with name
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: toolCallIndex,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
}
}
if (result.text) {
totalContent += result.text;
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: result.text }
: { content: result.text },
finish_reason: null,
},
],
})}\n\n`
);
}
}
console.log(
`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`
);
if (chunks.length === 0 && toolCalls.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],
})}\n\n`
);
}
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop",
},
],
usage,
})}\n\n`
);
chunks.push("data: [DONE]\n\n");
return new Response(chunks.join(""), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async refreshCredentials() {
return null;
}
}
export default CursorExecutor;

View File

@@ -0,0 +1,107 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
import { getAccessToken } from "../services/tokenRefresh.js";
export class DefaultExecutor extends BaseExecutor {
constructor(provider) {
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`;
}
switch (this.provider) {
case "claude":
case "glm":
case "kimi-coding":
case "minimax":
case "minimax-cn":
return `${this.config.baseUrl}?beta=true`;
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default:
return this.config.baseUrl;
}
}
buildHeaders(credentials, stream = true) {
const headers = { "Content-Type": "application/json", ...this.config.headers };
switch (this.provider) {
case "gemini":
credentials.apiKey
? (headers["x-goog-api-key"] = credentials.apiKey)
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);
break;
case "claude":
credentials.apiKey
? (headers["x-api-key"] = credentials.apiKey)
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);
break;
case "glm":
case "kimi-coding":
case "minimax":
case "minimax-cn":
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
break;
default:
if (this.provider?.startsWith?.("anthropic-compatible-")) {
if (credentials.apiKey) {
headers["x-api-key"] = credentials.apiKey;
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
if (!headers["anthropic-version"]) {
headers["anthropic-version"] = "2023-06-01";
}
} else {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}
}
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
/**
* For compatible providers, ensure the model name sent upstream
* is the clean model name without any internal routing prefix.
* e.g. "openapi-chat-anti/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
*/
transformRequest(model, body, stream, credentials) {
if (
this.provider?.startsWith?.("openai-compatible-") ||
this.provider?.startsWith?.("anthropic-compatible-")
) {
const cleanModel = model.includes("/") ? model.split("/").pop() : model;
return { ...body, model: cleanModel };
}
return body;
}
/**
* Refresh credentials via the centralized tokenRefresh service.
* Delegates to getAccessToken() which handles all providers with
* race-condition protection (deduplication via refreshPromiseCache).
*/
async refreshCredentials(credentials, log) {
if (!credentials.refreshToken) return null;
try {
return await getAccessToken(this.provider, credentials, log);
} catch (error) {
log?.error?.("TOKEN", `${this.provider} refresh error: ${error.message}`);
return null;
}
}
}
export default DefaultExecutor;

View File

@@ -0,0 +1,65 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
export class GeminiCLIExecutor extends BaseExecutor {
constructor() {
super("gemini-cli", PROVIDERS["gemini-cli"]);
}
buildUrl(model, stream, urlIndex = 0) {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${this.config.baseUrl}:${action}`;
}
buildHeaders(credentials, stream = true) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
...(stream && { Accept: "text/event-stream" }),
};
}
transformRequest(model, body, stream, credentials) {
if (!body.project && credentials?.projectId) {
body.project = credentials.projectId;
}
return body;
}
async refreshCredentials(credentials, log) {
if (!credentials.refreshToken) return null;
try {
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "Gemini CLI refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || credentials.refreshToken,
expiresIn: tokens.expires_in,
projectId: credentials.projectId,
};
} catch (error) {
log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`);
return null;
}
}
}
export default GeminiCLIExecutor;

View File

@@ -0,0 +1,139 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
import { getModelTargetFormat } from "../config/providerModels.js";
export class GithubExecutor extends BaseExecutor {
constructor() {
super("github", PROVIDERS.github);
}
buildUrl(model, stream, urlIndex = 0) {
const targetFormat = getModelTargetFormat("gh", model);
if (targetFormat === "openai-responses") {
return (
this.config.responsesBaseUrl ||
this.config.baseUrl?.replace(/\/chat\/completions\/?$/, "/responses") ||
"https://api.githubcopilot.com/responses"
);
}
return this.config.baseUrl;
}
buildHeaders(credentials, stream = true) {
const token = credentials.copilotToken || credentials.accessToken;
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.107.1",
"editor-plugin-version": "copilot-chat/0.26.7",
"user-agent": "GitHubCopilotChat/0.26.7",
"openai-intent": "conversation-panel",
"x-github-api-version": "2025-04-01",
"x-request-id":
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
Accept: stream ? "text/event-stream" : "application/json",
};
}
async refreshCopilotToken(githubAccessToken, log) {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
Authorization: `token ${githubAccessToken}`,
"User-Agent": "GithubCopilot/1.0",
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot/1.300.0",
Accept: "application/json",
},
});
if (!response.ok) return null;
const data = await response.json();
log?.info?.("TOKEN", "Copilot token refreshed");
return { token: data.token, expiresAt: data.expires_at };
} catch (error) {
log?.error?.("TOKEN", `Copilot refresh error: ${error.message}`);
return null;
}
}
async refreshGitHubToken(refreshToken, log) {
try {
const response = await fetch(OAUTH_ENDPOINTS.github.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: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "GitHub token refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN", `GitHub refresh error: ${error.message}`);
return null;
}
}
async refreshCredentials(credentials, log) {
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
if (!copilotResult && credentials.refreshToken) {
const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log);
if (githubTokens?.accessToken) {
copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log);
if (copilotResult) {
return {
...githubTokens,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
};
}
return githubTokens;
}
}
if (copilotResult) {
return {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
};
}
return null;
}
needsRefresh(credentials) {
// Always refresh if no copilotToken
if (!credentials.copilotToken) return true;
if (credentials.copilotTokenExpiresAt) {
// Handle both Unix timestamp (seconds) and ISO string
let expiresAtMs = credentials.copilotTokenExpiresAt;
if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) {
expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms
} else if (typeof expiresAtMs === "string") {
expiresAtMs = new Date(expiresAtMs).getTime();
}
if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true;
}
return super.needsRefresh(credentials);
}
}
export default GithubExecutor;

View File

@@ -0,0 +1,38 @@
import { AntigravityExecutor } from "./antigravity.js";
import { GeminiCLIExecutor } from "./gemini-cli.js";
import { GithubExecutor } from "./github.js";
import { KiroExecutor } from "./kiro.js";
import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { DefaultExecutor } from "./default.js";
const executors = {
antigravity: new AntigravityExecutor(),
"gemini-cli": new GeminiCLIExecutor(),
github: new GithubExecutor(),
kiro: new KiroExecutor(),
codex: new CodexExecutor(),
cursor: new CursorExecutor(),
cu: new CursorExecutor(), // Alias for cursor
};
const defaultCache = new Map();
export function getExecutor(provider) {
if (executors[provider]) return executors[provider];
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
return defaultCache.get(provider);
}
export function hasSpecializedExecutor(provider) {
return !!executors[provider];
}
export { BaseExecutor } from "./base.js";
export { AntigravityExecutor } from "./antigravity.js";
export { GeminiCLIExecutor } from "./gemini-cli.js";
export { GithubExecutor } from "./github.js";
export { KiroExecutor } from "./kiro.js";
export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { DefaultExecutor } from "./default.js";

511
open-sse/executors/kiro.js Normal file
View File

@@ -0,0 +1,511 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/constants.js";
import { v4 as uuidv4 } from "uuid";
import { refreshKiroToken } from "../services/tokenRefresh.js";
// ── CRC32 lookup table (IEEE polynomial, no dependency) ──
const CRC32_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
CRC32_TABLE[i] = c >>> 0;
}
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc = CRC32_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer)
* Uses AWS CodeWhisperer streaming API with AWS EventStream binary format
*/
export class KiroExecutor extends BaseExecutor {
constructor() {
super("kiro", PROVIDERS.kiro);
}
buildHeaders(credentials, stream = true) {
const headers = {
...this.config.headers,
"Amz-Sdk-Request": "attempt=1; max=3",
"Amz-Sdk-Invocation-Id": uuidv4(),
};
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
return headers;
}
transformRequest(model, body, stream, credentials) {
return body;
}
/**
* Custom execute for Kiro - handles AWS EventStream binary response
*/
async execute({ model, body, stream, credentials, signal, log }) {
const url = this.buildUrl(model, stream, 0);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal,
});
if (!response.ok) {
return { response, url, headers, transformedBody };
}
// For Kiro, we need to transform the binary EventStream to SSE
// Create a TransformStream to convert binary to SSE text
const transformedResponse = this.transformEventStreamToSSE(response, model);
return { response: transformedResponse, url, headers, transformedBody };
}
/**
* Transform AWS EventStream binary response to SSE text stream
* Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout
*/
transformEventStreamToSSE(response, model) {
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const responseId = `chatcmpl-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const state = {
endDetected: false,
finishEmitted: false,
hasToolCalls: false,
toolCallIndex: 0,
seenToolIds: new Map(),
};
const transformStream = new TransformStream({
async transform(chunk, controller) {
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
// Parse events from buffer
let iterations = 0;
const maxIterations = 1000;
while (buffer.length >= 16 && iterations < maxIterations) {
iterations++;
const view = new DataView(buffer.buffer, buffer.byteOffset);
const totalLength = view.getUint32(0, false);
if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break;
const eventData = buffer.slice(0, totalLength);
buffer = buffer.slice(totalLength);
const event = parseEventFrame(eventData);
if (!event) continue;
const eventType = event.headers[":event-type"] || "";
// Track total content length for token estimation
if (!state.totalContentLength) state.totalContentLength = 0;
if (!state.contextUsagePercentage) state.contextUsagePercentage = 0;
// Handle assistantResponseEvent
if (eventType === "assistantResponseEvent" && event.payload?.content) {
const content = event.payload.content;
state.totalContentLength += content.length;
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: chunkIndex === 0 ? { role: "assistant", content } : { content },
finish_reason: null,
},
],
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Handle codeEvent
if (eventType === "codeEvent" && event.payload?.content) {
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { content: event.payload.content },
finish_reason: null,
},
],
};
chunkIndex++;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Handle toolUseEvent
if (eventType === "toolUseEvent" && event.payload) {
state.hasToolCalls = true;
const toolUse = event.payload;
const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse];
for (const singleToolUse of toolUses) {
const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`;
const toolName = singleToolUse.name || "";
const toolInput = singleToolUse.input;
let toolIndex;
const isNewTool = !state.seenToolIds.has(toolCallId);
if (isNewTool) {
toolIndex = state.toolCallIndex++;
state.seenToolIds.set(toolCallId, toolIndex);
const startChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
...(chunkIndex === 0 ? { role: "assistant" } : {}),
tool_calls: [
{
index: toolIndex,
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: "",
},
},
],
},
finish_reason: null,
},
],
};
chunkIndex++;
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)
);
} else {
toolIndex = state.seenToolIds.get(toolCallId);
}
if (toolInput !== undefined) {
let argumentsStr;
if (typeof toolInput === "string") {
argumentsStr = toolInput;
} else if (typeof toolInput === "object") {
argumentsStr = JSON.stringify(toolInput);
} else {
continue;
}
const argsChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: toolIndex,
function: {
arguments: argumentsStr,
},
},
],
},
finish_reason: null,
},
],
};
chunkIndex++;
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`)
);
}
}
}
// Handle messageStopEvent
if (eventType === "messageStopEvent") {
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop",
},
],
};
state.finishEmitted = true;
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// Handle contextUsageEvent to extract contextUsagePercentage
if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) {
state.contextUsagePercentage = event.payload.contextUsagePercentage;
// Mark that we received context usage event
state.hasContextUsage = true;
}
// Handle meteringEvent - mark that we received it
if (eventType === "meteringEvent") {
state.hasMeteringEvent = true;
}
// Handle metricsEvent for token usage
if (eventType === "metricsEvent") {
// Extract usage data from metricsEvent payload
const metrics = event.payload?.metricsEvent || event.payload;
if (metrics && typeof metrics === "object") {
const inputTokens = metrics.inputTokens || 0;
const outputTokens = metrics.outputTokens || 0;
if (inputTokens > 0 || outputTokens > 0) {
state.usage = {
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
};
}
}
}
// Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent
if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) {
state.finishEmitted = true;
// Estimate tokens if not available from events
if (!state.usage) {
// Estimate output tokens from content length
const estimatedOutputTokens =
state.totalContentLength > 0
? Math.max(1, Math.floor(state.totalContentLength / 4))
: 0;
// Estimate input tokens from contextUsagePercentage
// Kiro models typically have 200k context window
const estimatedInputTokens =
state.contextUsagePercentage > 0
? Math.floor((state.contextUsagePercentage * 200000) / 100)
: 0;
state.usage = {
prompt_tokens: estimatedInputTokens,
completion_tokens: estimatedOutputTokens,
total_tokens: estimatedInputTokens + estimatedOutputTokens,
};
}
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop",
},
],
};
// Include usage in final chunk if available
if (state.usage) {
finishChunk.usage = state.usage;
}
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)
);
}
}
if (iterations >= maxIterations) {
console.warn("[Kiro] Max iterations reached in event parsing");
}
},
flush(controller) {
// Emit finish chunk if not already sent
if (!state.finishEmitted) {
state.finishEmitted = true;
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: state.hasToolCalls ? "tool_calls" : "stop",
},
],
};
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
}
// Send final done message
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
},
});
// Pipe response body through transform stream
const transformedStream = response.body.pipeThrough(transformStream);
return new Response(transformedStream, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async refreshCredentials(credentials, log) {
if (!credentials.refreshToken) return null;
try {
// Use centralized refreshKiroToken function (handles both AWS SSO OIDC and Social Auth)
const result = await refreshKiroToken(
credentials.refreshToken,
credentials.providerSpecificData,
log
);
return result;
} catch (error) {
log?.error?.("TOKEN", `Kiro refresh error: ${error.message}`);
return null;
}
}
}
/**
* Parse AWS EventStream frame
*/
function parseEventFrame(data) {
try {
const view = new DataView(data.buffer, data.byteOffset);
const totalLength = view.getUint32(0, false);
const headersLength = view.getUint32(4, false);
// ── CRC32 validation ──
// Prelude CRC covers bytes [0..7] (totalLength + headersLength)
const preludeCRC = view.getUint32(8, false);
const computedPreludeCRC = crc32(data.slice(0, 8));
if (preludeCRC !== computedPreludeCRC) {
console.warn(
`[Kiro] Prelude CRC mismatch: expected ${preludeCRC}, got ${computedPreludeCRC} — skipping corrupted frame`
);
return null;
}
// Message CRC covers bytes [0..totalLength-5] (everything except the CRC itself)
const messageCRC = view.getUint32(data.length - 4, false);
const computedMessageCRC = crc32(data.slice(0, data.length - 4));
if (messageCRC !== computedMessageCRC) {
console.warn(
`[Kiro] Message CRC mismatch: expected ${messageCRC}, got ${computedMessageCRC} — skipping corrupted frame`
);
return null;
}
// Parse headers
const headers = {};
let offset = 12; // After prelude
const headerEnd = 12 + headersLength;
while (offset < headerEnd && offset < data.length) {
const nameLen = data[offset];
offset++;
if (offset + nameLen > data.length) break;
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
offset += nameLen;
const headerType = data[offset];
offset++;
if (headerType === 7) {
// String type
const valueLen = (data[offset] << 8) | data[offset + 1];
offset += 2;
if (offset + valueLen > data.length) break;
const value = new TextDecoder().decode(data.slice(offset, offset + valueLen));
offset += valueLen;
headers[name] = value;
} else {
break;
}
}
// Parse payload
const payloadStart = 12 + headersLength;
const payloadEnd = data.length - 4; // Exclude message CRC
let payload = null;
if (payloadEnd > payloadStart) {
const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd));
// Skip empty or whitespace-only payloads
if (!payloadStr || !payloadStr.trim()) {
return { headers, payload: null };
}
try {
payload = JSON.parse(payloadStr);
} catch (parseError) {
// Log parse error for debugging
console.warn(
`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`
);
payload = { raw: payloadStr };
}
}
return { headers, payload };
} catch (err) {
console.warn(`[Kiro] Frame parse error: ${err.message}`);
return null;
}
}
export default KiroExecutor;

View File

@@ -0,0 +1,567 @@
import { detectFormat, getTargetFormat } from "../services/provider.js";
import { translateRequest, needsTranslation } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import {
createSSETransformStreamWithLogger,
createPassthroughStreamWithLogger,
COLORS,
} from "../utils/stream.js";
import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.js";
import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/constants.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import {
saveRequestUsage,
trackPendingRequest,
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
import { translateNonStreamingResponse } from "./responseTranslator.js";
import { extractUsageFromResponse } from "./usageExtractor.js";
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.js";
import {
withRateLimit,
updateFromHeaders,
initializeRateLimits,
} from "../services/rateLimitManager.js";
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
* @param {object} options
* @param {object} options.body - Request body
* @param {object} options.modelInfo - { provider, model }
* @param {object} options.credentials - Provider credentials
* @param {object} options.log - Logger instance (optional)
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
* @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status)
* @param {function} options.onDisconnect - Callback when client disconnects
* @param {string} options.connectionId - Connection ID for usage tracking
* @param {object} options.apiKeyInfo - API key metadata for usage attribution
*/
export async function handleChatCore({
body,
modelInfo,
credentials,
log,
onCredentialsRefreshed,
onRequestSuccess,
onDisconnect,
clientRawRequest,
connectionId,
apiKeyInfo = null,
userAgent,
comboName,
}) {
const { provider, model } = modelInfo;
const startTime = Date.now();
// Initialize rate limit settings from persisted DB (once, lazy)
await initializeRateLimits();
const sourceFormat = detectFormat(body);
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
const isResponsesEndpoint = endpointPath.endsWith("/responses");
// Check for bypass patterns (warmup, skip) - return fake response
const bypassResponse = handleBypassRequest(body, model, userAgent);
if (bypassResponse) {
return bypassResponse;
}
// Detect source format and get target format
// Model-specific targetFormat takes priority over provider default
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Default to streaming unless client explicitly sets stream: false
const stream = body.stream !== false;
// Create request logger for this session: sourceFormat_targetFormat_model
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
// 0. Log client raw request (before any conversion)
if (clientRawRequest) {
reqLogger.logClientRawRequest(
clientRawRequest.endpoint,
clientRawRequest.body,
clientRawRequest.headers
);
}
// 1. Log raw request from client
reqLogger.logRawRequest(body);
log?.debug?.("FORMAT", `${sourceFormat}${targetFormat} | stream=${stream}`);
// Translate request (pass reqLogger for intermediate logging)
let translatedBody = body;
try {
translatedBody = translateRequest(
sourceFormat,
targetFormat,
model,
body,
stream,
credentials,
provider,
reqLogger
);
} catch (error) {
const parsedStatus = Number(error?.statusCode);
const statusCode =
Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599
? parsedStatus
: HTTP_STATUS.SERVER_ERROR;
const message = error?.message || "Invalid request";
const errorType = typeof error?.errorType === "string" ? error.errorType : null;
log?.warn?.("TRANSLATE", `Request translation failed: ${message}`);
if (errorType) {
return {
success: false,
status: statusCode,
error: message,
response: new Response(
JSON.stringify({
error: {
message,
type: errorType,
code: errorType,
},
}),
{
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
),
};
}
return createErrorResult(statusCode, message);
}
// Extract toolNameMap for response translation (Claude OAuth)
const toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap;
// Update model in body
translatedBody.model = model;
// Get executor for this provider
const executor = getExecutor(provider);
// Track pending request
trackPendingRequest(model, provider, connectionId, true);
// Log start
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
const msgCount =
translatedBody.messages?.length ||
translatedBody.contents?.length ||
translatedBody.request?.contents?.length ||
0;
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
// Create stream controller for disconnect detection
const streamController = createStreamController({ onDisconnect, log, provider, model });
// Execute request using executor (handles URL building, headers, fallback, transform)
let providerResponse;
let providerUrl;
let providerHeaders;
let finalBody;
try {
const result = await withRateLimit(provider, connectionId, model, () =>
executor.execute({
model,
body: translatedBody,
stream,
credentials,
signal: streamController.signal,
log,
})
);
providerResponse = result.response;
providerUrl = result.url;
providerHeaders = result.headers;
finalBody = result.transformedBody;
// Log target request (final request to provider)
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
// Update rate limiter from response headers (learn limits dynamically)
updateFromHeaders(
provider,
connectionId,
providerResponse.headers,
providerResponse.status,
model
);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY,
model,
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
error: error.message,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
}).catch(() => {});
if (error.name === "AbortError") {
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
// Handle 401/403 - try token refresh using executor
if (
providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
providerResponse.status === HTTP_STATUS.FORBIDDEN
) {
const newCredentials = await refreshWithRetry(
() => executor.refreshCredentials(credentials, log),
3,
log
);
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
// Update credentials
Object.assign(credentials, newCredentials);
// Notify caller about refreshed credentials
if (onCredentialsRefreshed && newCredentials) {
await onCredentialsRefreshed(newCredentials);
}
// Retry with new credentials
try {
const retryResult = await executor.execute({
model,
body: translatedBody,
stream,
credentials,
signal: streamController.signal,
log,
});
if (retryResult.response.ok) {
providerResponse = retryResult.response;
providerUrl = retryResult.url;
}
} catch (retryError) {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
}
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
}
}
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false);
const { statusCode, message, retryAfterMs } = await parseUpstreamError(
providerResponse,
provider
);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(
() => {}
);
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: statusCode,
model,
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
error: message,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
}).catch(() => {});
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
// Log Antigravity retry time if available
if (retryAfterMs && provider === "antigravity") {
const retrySeconds = Math.ceil(retryAfterMs / 1000);
log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`);
}
// Log error with full request body for debugging
reqLogger.logError(new Error(message), finalBody || translatedBody);
// Update rate limiter from error response headers
updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model);
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
// Non-streaming response
if (!stream) {
trackPendingRequest(model, provider, connectionId, false);
const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase();
let responseBody;
const rawBody = await providerResponse.text();
const looksLikeSSE =
contentType.includes("text/event-stream") || /(^|\n)\s*(event|data):/m.test(rawBody);
if (looksLikeSSE) {
// Upstream returned SSE even though stream=false; convert best-effort to JSON.
const parsedFromSSE =
targetFormat === FORMATS.OPENAI_RESPONSES
? parseSSEToResponsesOutput(rawBody, model)
: parseSSEToOpenAIResponse(rawBody, model);
if (!parsedFromSSE) {
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
"Invalid SSE response for non-streaming request"
);
}
responseBody = parsedFromSSE;
} else {
try {
responseBody = rawBody ? JSON.parse(rawBody) : {};
} catch {
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON response from provider");
}
}
// Notify success - caller can clear error status if needed
if (onRequestSuccess) {
await onRequestSuccess();
}
// Log usage for non-streaming responses
const usage = extractUsageFromResponse(responseBody, provider);
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(
() => {}
);
// Save structured call log with full payloads
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: 200,
model,
provider,
connectionId,
duration: Date.now() - startTime,
tokens: usage,
requestBody: body,
responseBody,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
}).catch(() => {});
if (usage && typeof usage === "object") {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
saveRequestUsage({
provider: provider || "unknown",
model: model || "unknown",
tokens: usage,
timestamp: new Date().toISOString(),
connectionId: connectionId || undefined,
apiKeyId: apiKeyInfo?.id || undefined,
apiKeyName: apiKeyInfo?.name || undefined,
}).catch((err) => {
console.error("Failed to save usage stats:", err.message);
});
}
// Translate response to client's expected format (usually OpenAI)
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
// Add buffer and filter usage for client (to prevent CLI context errors)
if (translatedResponse?.usage) {
const buffered = addBufferToUsage(translatedResponse.usage);
translatedResponse.usage = filterUsageForFormat(buffered, sourceFormat);
} else {
// Fallback: estimate usage when provider didn't return any
const contentLength = JSON.stringify(
translatedResponse?.choices?.[0]?.message?.content || ""
).length;
if (contentLength > 0) {
const estimated = estimateUsage(body, contentLength, sourceFormat);
translatedResponse.usage = filterUsageForFormat(estimated, sourceFormat);
}
}
return {
success: true,
response: new Response(JSON.stringify(translatedResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// Streaming response
// Notify success - caller can clear error status if needed
if (onRequestSuccess) {
await onRequestSuccess();
}
const responseHeaders = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
};
// Create transform stream with logger for streaming response
let transformStream;
// Callback to save call log when stream completes (streaming calls were never logged before!)
const onStreamComplete = ({ status: streamStatus, usage: streamUsage }) => {
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: streamStatus || 200,
model,
provider,
connectionId,
duration: Date.now() - startTime,
tokens: streamUsage || {},
requestBody: body,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
}).catch(() => {});
};
// For Codex provider, translate response from openai-responses to openai (Chat Completions) format
// UNLESS client is Droid CLI which expects openai-responses format back
const isDroidCLI =
userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
const needsCodexTranslation =
provider === "codex" &&
targetFormat === FORMATS.OPENAI_RESPONSES &&
sourceFormat === FORMATS.OPENAI &&
!isResponsesEndpoint &&
!isDroidCLI;
if (needsCodexTranslation) {
// Codex returns openai-responses, translate to openai (Chat Completions) that clients expect
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
transformStream = createSSETransformStreamWithLogger(
"openai-responses",
"openai",
provider,
reqLogger,
toolNameMap,
model,
connectionId,
body,
onStreamComplete,
apiKeyInfo
);
} else if (needsTranslation(targetFormat, sourceFormat)) {
// Standard translation for other providers
log?.debug?.("STREAM", `Translation mode: ${targetFormat}${sourceFormat}`);
transformStream = createSSETransformStreamWithLogger(
targetFormat,
sourceFormat,
provider,
reqLogger,
toolNameMap,
model,
connectionId,
body,
onStreamComplete,
apiKeyInfo
);
} else {
log?.debug?.("STREAM", `Standard passthrough mode`);
transformStream = createPassthroughStreamWithLogger(
provider,
reqLogger,
model,
connectionId,
body,
onStreamComplete,
apiKeyInfo
);
}
// Pipe response through transform with disconnect detection
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
return {
success: true,
response: new Response(transformedBody, {
headers: responseHeaders,
}),
};
}
/**
* Check if token is expired or about to expire
*/
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
if (!expiresAt) return false;
const expiresAtMs = new Date(expiresAt).getTime();
return expiresAtMs - Date.now() < bufferMs;
}

View File

@@ -0,0 +1,171 @@
/**
* Embedding Handler
*
* Handles POST /v1/embeddings requests.
* Proxies to upstream embedding providers using OpenAI-compatible format.
*
* Request format (OpenAI-compatible):
* {
* "model": "nebius/Qwen/Qwen3-Embedding-8B",
* "input": "text" | ["text1", "text2"],
* "dimensions": 4096, // optional
* "encoding_format": "float" // optional
* }
*/
import { getEmbeddingProvider, parseEmbeddingModel } from "../config/embeddingRegistry.js";
import { saveCallLog } from "@/lib/usageDb.js";
/**
* Handle embedding request
* @param {object} options
* @param {object} options.body - Request body
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
* @param {object} options.log - Logger
*/
export async function handleEmbedding({ body, credentials, log }) {
const { provider, model } = parseEmbeddingModel(body.model);
const startTime = Date.now();
// Summarized request body for call log (avoid storing large embedding input arrays)
const logRequestBody = {
model: body.model,
input_count: Array.isArray(body.input) ? body.input.length : 1,
dimensions: body.dimensions || undefined,
};
if (!provider) {
return {
success: false,
status: 400,
error: `Invalid embedding model: ${body.model}. Use format: provider/model`,
};
}
const providerConfig = getEmbeddingProvider(provider);
if (!providerConfig) {
return {
success: false,
status: 400,
error: `Unknown embedding provider: ${provider}`,
};
}
// Build upstream request
const upstreamBody = {
model: model,
input: body.input,
};
// Pass optional parameters
if (body.dimensions !== undefined) upstreamBody.dimensions = body.dimensions;
if (body.encoding_format !== undefined) upstreamBody.encoding_format = body.encoding_format;
// Build headers
const headers = {
"Content-Type": "application/json",
};
const token = credentials.apiKey || credentials.accessToken;
if (providerConfig.authHeader === "bearer") {
headers["Authorization"] = `Bearer ${token}`;
} else if (providerConfig.authHeader === "x-api-key") {
headers["x-api-key"] = token;
}
if (log) {
log.info(
"EMBED",
`${provider}/${model} | input: ${Array.isArray(body.input) ? body.input.length + " items" : "1 item"}`
);
}
try {
const response = await fetch(providerConfig.baseUrl, {
method: "POST",
headers,
body: JSON.stringify(upstreamBody),
});
if (!response.ok) {
const errorText = await response.text();
if (log) {
log.error("EMBED", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
}
// Save error call log for Logger panel
saveCallLog({
method: "POST",
path: "/v1/embeddings",
status: response.status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
requestBody: logRequestBody,
}).catch(() => {});
return {
success: false,
status: response.status,
error: errorText,
};
}
const data = await response.json();
// Save success call log for Logger panel
// Embeddings only have input tokens (prompt_tokens + total_tokens), no output/completion tokens
saveCallLog({
method: "POST",
path: "/v1/embeddings",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
tokens: {
prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0,
completion_tokens: 0,
},
requestBody: logRequestBody,
responseBody: {
usage: data.usage || null,
object: "list",
data_count: data.data?.length || 0,
},
}).catch(() => {});
// Normalize response to OpenAI format
return {
success: true,
data: {
object: "list",
data: data.data || data,
model: `${provider}/${model}`,
usage: data.usage || { prompt_tokens: 0, total_tokens: 0 },
},
};
} catch (err) {
if (log) {
log.error("EMBED", `${provider} fetch error: ${err.message}`);
}
// Save exception call log for Logger panel
saveCallLog({
method: "POST",
path: "/v1/embeddings",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
requestBody: logRequestBody,
}).catch(() => {});
return {
success: false,
status: 502,
error: `Embedding provider error: ${err.message}`,
};
}
}

View File

@@ -0,0 +1,341 @@
/**
* Image Generation Handler
*
* Handles POST /v1/images/generations requests.
* Proxies to upstream image generation providers using OpenAI-compatible format.
*
* Request format (OpenAI-compatible):
* {
* "model": "openai/dall-e-3",
* "prompt": "a beautiful sunset over mountains",
* "n": 1,
* "size": "1024x1024",
* "quality": "standard", // optional: "standard" | "hd"
* "response_format": "url" // optional: "url" | "b64_json"
* }
*/
import { getImageProvider, parseImageModel } from "../config/imageRegistry.js";
import { saveCallLog } from "@/lib/usageDb.js";
/**
* Handle image generation request
* @param {object} options
* @param {object} options.body - Request body
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
* @param {object} options.log - Logger
*/
export async function handleImageGeneration({ body, credentials, log }) {
const { provider, model } = parseImageModel(body.model);
if (!provider) {
return {
success: false,
status: 400,
error: `Invalid image model: ${body.model}. Use format: provider/model`,
};
}
const providerConfig = getImageProvider(provider);
if (!providerConfig) {
return {
success: false,
status: 400,
error: `Unknown image provider: ${provider}`,
};
}
// Route to format-specific handler
if (providerConfig.format === "gemini-image") {
return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log });
}
return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log });
}
/**
* Handle Gemini-format image generation (Antigravity / Nano Banana)
* Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"]
*/
async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }) {
const startTime = Date.now();
const url = `${providerConfig.baseUrl}/${model}:generateContent`;
const provider = "antigravity";
// Summarized request for call log
const logRequestBody = {
model: body.model,
prompt:
typeof body.prompt === "string"
? body.prompt.slice(0, 200)
: String(body.prompt ?? "").slice(0, 200),
size: body.size || "default",
n: body.n || 1,
};
const geminiBody = {
contents: [
{
parts: [{ text: body.prompt }],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
};
const token = credentials.accessToken || credentials.apiKey;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
if (log) {
const promptPreview =
typeof body.prompt === "string"
? body.prompt.slice(0, 60)
: String(body.prompt ?? "").slice(0, 60);
log.info(
"IMAGE",
`antigravity/${model} (gemini) | prompt: "${promptPreview}..." | format: gemini-image`
);
}
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(geminiBody),
});
if (!response.ok) {
const errorText = await response.text();
if (log) {
log.error("IMAGE", `antigravity error ${response.status}: ${errorText.slice(0, 200)}`);
}
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: response.status,
model: `antigravity/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: response.status, error: errorText };
}
const data = await response.json();
// Extract image data from Gemini response
const images = [];
const candidates = data.candidates || [];
for (const candidate of candidates) {
const parts = candidate.content?.parts || [];
for (const part of parts) {
if (part.inlineData) {
images.push({
b64_json: part.inlineData.data,
revised_prompt: parts.find((p) => p.text)?.text || body.prompt,
});
}
}
}
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 200,
model: `antigravity/${model}`,
provider,
duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 },
requestBody: logRequestBody,
responseBody: { images_count: images.length },
}).catch(() => {});
return {
success: true,
data: {
created: Math.floor(Date.now() / 1000),
data: images,
},
};
} catch (err) {
if (log) {
log.error("IMAGE", `antigravity fetch error: ${err.message}`);
}
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 502,
model: `antigravity/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
requestBody: logRequestBody,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
}
}
/**
* Handle OpenAI-compatible image generation (standard providers + Nebius fallback)
*/
async function handleOpenAIImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}) {
const startTime = Date.now();
// Summarized request for call log
const logRequestBody = {
model: body.model,
prompt:
typeof body.prompt === "string"
? body.prompt.slice(0, 200)
: String(body.prompt ?? "").slice(0, 200),
size: body.size || "default",
n: body.n || 1,
quality: body.quality || undefined,
};
// Build upstream request (OpenAI-compatible format)
const upstreamBody = {
model: model,
prompt: body.prompt,
};
// Pass optional parameters
if (body.n !== undefined) upstreamBody.n = body.n;
if (body.size !== undefined) upstreamBody.size = body.size;
if (body.quality !== undefined) upstreamBody.quality = body.quality;
if (body.response_format !== undefined) upstreamBody.response_format = body.response_format;
if (body.style !== undefined) upstreamBody.style = body.style;
// Build headers
const headers = {
"Content-Type": "application/json",
};
const token = credentials.apiKey || credentials.accessToken;
if (providerConfig.authHeader === "bearer") {
headers["Authorization"] = `Bearer ${token}`;
} else if (providerConfig.authHeader === "x-api-key") {
headers["x-api-key"] = token;
}
if (log) {
const promptPreview =
typeof body.prompt === "string"
? body.prompt.slice(0, 60)
: String(body.prompt ?? "").slice(0, 60);
log.info(
"IMAGE",
`${provider}/${model} | prompt: "${promptPreview}..." | size: ${body.size || "default"}`
);
}
const requestBody = JSON.stringify(upstreamBody);
// Try primary URL
let result = await fetchImageEndpoint(
providerConfig.baseUrl,
headers,
requestBody,
provider,
log
);
// Fallback for providers with fallbackUrl (e.g., Nebius)
if (
!result.success &&
providerConfig.fallbackUrl &&
[404, 410, 502, 503].includes(result.status)
) {
if (log) {
log.info("IMAGE", `${provider}: primary URL failed (${result.status}), trying fallback...`);
}
result = await fetchImageEndpoint(
providerConfig.fallbackUrl,
headers,
requestBody,
provider,
log
);
}
// Save call log after result is determined
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: result.status || (result.success ? 200 : 502),
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 },
error: result.success
? null
: typeof result.error === "string"
? result.error.slice(0, 500)
: null,
requestBody: logRequestBody,
responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null,
}).catch(() => {});
return result;
}
/**
* Fetch a single image endpoint and normalize response
*/
async function fetchImageEndpoint(url, headers, body, provider, log) {
try {
const response = await fetch(url, {
method: "POST",
headers,
body,
});
if (!response.ok) {
const errorText = await response.text();
if (log) {
log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
}
return {
success: false,
status: response.status,
error: errorText,
};
}
const data = await response.json();
// Normalize response to OpenAI format
return {
success: true,
data: {
created: data.created || Math.floor(Date.now() / 1000),
data: data.data || [],
},
};
} catch (err) {
if (log) {
log.error("IMAGE", `${provider} fetch error: ${err.message}`);
}
return {
success: false,
status: 502,
error: `Image provider error: ${err.message}`,
};
}
}

View File

@@ -0,0 +1,290 @@
import { FORMATS } from "../translator/formats.js";
/**
* Translate non-streaming response to OpenAI format
* Handles different provider response formats (Gemini, Claude, etc.)
*/
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
// If already in source format (usually OpenAI), return as-is
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) {
return responseBody;
}
// Handle OpenAI Responses API format
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
const response =
responseBody?.object === "response" ? responseBody : responseBody?.response || responseBody;
const output = Array.isArray(response?.output) ? response.output : [];
const usage = response?.usage || responseBody?.usage;
let textContent = "";
let reasoningContent = "";
const toolCalls = [];
for (const item of output) {
if (!item || typeof item !== "object") continue;
if (item.type === "message" && Array.isArray(item.content)) {
for (const part of item.content) {
if (!part || typeof part !== "object") continue;
if (part.type === "output_text" && typeof part.text === "string") {
textContent += part.text;
} else if (part.type === "summary_text" && typeof part.text === "string") {
reasoningContent += part.text;
}
}
} else if (item.type === "reasoning" && Array.isArray(item.summary)) {
for (const part of item.summary) {
if (part?.type === "summary_text" && typeof part.text === "string") {
reasoningContent += part.text;
}
}
} else if (item.type === "function_call") {
const callId = item.call_id || item.id || `call_${Date.now()}_${toolCalls.length}`;
const fnArgs =
typeof item.arguments === "string"
? item.arguments
: JSON.stringify(item.arguments || {});
toolCalls.push({
id: callId,
type: "function",
function: {
name: item.name || "",
arguments: fnArgs,
},
});
}
}
const message = { role: "assistant" };
if (textContent) {
message.content = textContent;
}
if (reasoningContent) {
message.reasoning_content = reasoningContent;
}
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
if (!message.content && !message.tool_calls) {
message.content = "";
}
const createdAt = Number(response?.created_at) || Math.floor(Date.now() / 1000);
const model = response?.model || responseBody?.model || "openai-responses";
const finishReason = toolCalls.length > 0 ? "tool_calls" : "stop";
const result = {
id: `chatcmpl-${response?.id || Date.now()}`,
object: "chat.completion",
created: createdAt,
model,
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage && typeof usage === "object") {
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
result.usage = {
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
};
if (usage.reasoning_tokens > 0) {
result.usage.completion_tokens_details = {
reasoning_tokens: usage.reasoning_tokens,
};
}
if (usage.cache_read_input_tokens > 0 || usage.cache_creation_input_tokens > 0) {
result.usage.prompt_tokens_details = {};
if (usage.cache_read_input_tokens > 0) {
result.usage.prompt_tokens_details.cached_tokens = usage.cache_read_input_tokens;
}
if (usage.cache_creation_input_tokens > 0) {
result.usage.prompt_tokens_details.cache_creation_tokens =
usage.cache_creation_input_tokens;
}
}
}
return result;
}
// Handle Gemini/Antigravity format
if (
targetFormat === FORMATS.GEMINI ||
targetFormat === FORMATS.ANTIGRAVITY ||
targetFormat === FORMATS.GEMINI_CLI
) {
const response = responseBody.response || responseBody;
if (!response?.candidates?.[0]) {
return responseBody; // Can't translate, return raw
}
const candidate = response.candidates[0];
const content = candidate.content;
const usage = response.usageMetadata || responseBody.usageMetadata;
// Build message content
let textContent = "";
const toolCalls = [];
let reasoningContent = "";
if (content?.parts) {
for (const part of content.parts) {
// Handle thinking/reasoning
if (part.thought === true && part.text) {
reasoningContent += part.text;
}
// Regular text
else if (part.text !== undefined) {
textContent += part.text;
}
// Function calls
if (part.functionCall) {
toolCalls.push({
id: `call_${part.functionCall.name}_${Date.now()}_${toolCalls.length}`,
type: "function",
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args || {}),
},
});
}
}
}
// Build OpenAI format message
const message = { role: "assistant" };
if (textContent) {
message.content = textContent;
}
if (reasoningContent) {
message.reasoning_content = reasoningContent;
}
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
// If no content at all, set content to empty string
if (!message.content && !message.tool_calls) {
message.content = "";
}
// Determine finish reason
let finishReason = (candidate.finishReason || "stop").toLowerCase();
if (finishReason === "stop" && toolCalls.length > 0) {
finishReason = "tool_calls";
}
const result = {
id: `chatcmpl-${response.responseId || Date.now()}`,
object: "chat.completion",
created: Math.floor(new Date(response.createTime || Date.now()).getTime() / 1000),
model: response.modelVersion || "gemini",
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
// Add usage if available (match streaming translator: add thoughtsTokenCount to prompt_tokens)
if (usage) {
result.usage = {
prompt_tokens: (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0),
completion_tokens: usage.candidatesTokenCount || 0,
total_tokens: usage.totalTokenCount || 0,
};
if (usage.thoughtsTokenCount > 0) {
result.usage.completion_tokens_details = {
reasoning_tokens: usage.thoughtsTokenCount,
};
}
}
return result;
}
// Handle Claude format
if (targetFormat === FORMATS.CLAUDE) {
if (!responseBody.content) {
return responseBody; // Can't translate, return raw
}
let textContent = "";
let thinkingContent = "";
const toolCalls = [];
for (const block of responseBody.content) {
if (block.type === "text") {
textContent += block.text;
} else if (block.type === "thinking") {
thinkingContent += block.thinking || "";
} else if (block.type === "tool_use") {
toolCalls.push({
id: block.id,
type: "function",
function: {
name: block.name,
arguments: JSON.stringify(block.input || {}),
},
});
}
}
const message = { role: "assistant" };
if (textContent) {
message.content = textContent;
}
if (thinkingContent) {
message.reasoning_content = thinkingContent;
}
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
if (!message.content && !message.tool_calls) {
message.content = "";
}
let finishReason = responseBody.stop_reason || "stop";
if (finishReason === "end_turn") finishReason = "stop";
if (finishReason === "tool_use") finishReason = "tool_calls";
const result = {
id: `chatcmpl-${responseBody.id || Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: responseBody.model || "claude",
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (responseBody.usage) {
result.usage = {
prompt_tokens: responseBody.usage.input_tokens || 0,
completion_tokens: responseBody.usage.output_tokens || 0,
total_tokens:
(responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0),
};
}
return result;
}
// Unknown format, return as-is
return responseBody;
}

View File

@@ -0,0 +1,79 @@
/**
* Responses API Handler for Workers
* Converts Chat Completions to Codex Responses API format
*/
import { handleChatCore } from "./chatCore.js";
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
/**
* Handle /v1/responses request
* @param {object} options
* @param {object} options.body - Request body (Responses API format)
* @param {object} options.modelInfo - { provider, model }
* @param {object} options.credentials - Provider credentials
* @param {object} options.log - Logger instance (optional)
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
* @param {function} options.onRequestSuccess - Callback when request succeeds
* @param {function} options.onDisconnect - Callback when client disconnects
* @param {string} options.connectionId - Connection ID for usage tracking
* @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>}
*/
export async function handleResponsesCore({
body,
modelInfo,
credentials,
log,
onCredentialsRefreshed,
onRequestSuccess,
onDisconnect,
connectionId,
}) {
// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body);
// Ensure stream is enabled
convertedBody.stream = true;
// Call chat core handler
const result = await handleChatCore({
body: convertedBody,
modelInfo,
credentials,
log,
onCredentialsRefreshed,
onRequestSuccess,
onDisconnect,
connectionId,
});
if (!result.success || !result.response) {
return result;
}
const response = result.response;
const contentType = response.headers.get("Content-Type") || "";
// If not SSE or error, return as-is
if (!contentType.includes("text/event-stream") || response.status !== 200) {
return result;
}
// Transform SSE stream to Responses API format (no logging in worker)
const transformStream = createResponsesApiTransformStream(null);
const transformedBody = response.body.pipeThrough(transformStream);
return {
success: true,
response: new Response(transformedBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
},
}),
};
}

View File

@@ -0,0 +1,125 @@
/**
* Convert OpenAI-style SSE chunks into a single non-streaming JSON response.
* Used as a fallback when upstream returns text/event-stream for stream=false.
*/
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const lines = String(rawSSE || "").split("\n");
const chunks = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
chunks.push(JSON.parse(payload));
} catch {
// Ignore malformed SSE lines and continue best-effort parsing.
}
}
if (chunks.length === 0) return null;
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
let finishReason = "stop";
let usage = null;
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
if (choice?.finish_reason) {
finishReason = choice.finish_reason;
}
if (chunk?.usage && typeof chunk.usage === "object") {
usage = chunk.usage;
}
}
const message = {
role: "assistant",
content: contentParts.join(""),
};
if (reasoningParts.length > 0) {
message.reasoning_content = reasoningParts.join("");
}
const result = {
id: first.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: first.created || Math.floor(Date.now() / 1000),
model: first.model || fallbackModel || "unknown",
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage) {
result.usage = usage;
}
return result;
}
/**
* Convert Responses API SSE events into a single non-streaming response object.
* Expects events such as response.created / response.in_progress / response.completed.
*/
export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
const lines = String(rawSSE || "").split("\n");
const events = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
events.push(JSON.parse(payload));
} catch {
// Ignore malformed lines and continue best-effort parsing.
}
}
if (events.length === 0) return null;
let completed = null;
let latestResponse = null;
for (const evt of events) {
if (evt?.type === "response.completed" && evt.response) {
completed = evt.response;
}
if (evt?.response && typeof evt.response === "object") {
latestResponse = evt.response;
} else if (evt?.object === "response") {
latestResponse = evt;
}
}
const picked = completed || latestResponse;
if (!picked || typeof picked !== "object") return null;
return {
id: picked.id || `resp_${Date.now()}`,
object: "response",
model: picked.model || fallbackModel || "unknown",
output: Array.isArray(picked.output) ? picked.output : [],
usage: picked.usage || null,
status: picked.status || (completed ? "completed" : "in_progress"),
created_at: picked.created_at || Math.floor(Date.now() / 1000),
metadata: picked.metadata || {},
};
}

View File

@@ -0,0 +1,64 @@
/**
* Extract usage from non-streaming response body
* Handles different provider response formats
*/
export function extractUsageFromResponse(responseBody, provider) {
if (!responseBody || typeof responseBody !== "object") return null;
// OpenAI format (has prompt_tokens / completion_tokens)
if (
responseBody.usage &&
typeof responseBody.usage === "object" &&
responseBody.usage.prompt_tokens !== undefined
) {
return {
prompt_tokens: responseBody.usage.prompt_tokens || 0,
completion_tokens: responseBody.usage.completion_tokens || 0,
cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens,
reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens,
};
}
// OpenAI Responses API format (input_tokens / output_tokens)
const responsesUsage = responseBody.response?.usage || responseBody.usage;
if (
responsesUsage &&
typeof responsesUsage === "object" &&
(responsesUsage.input_tokens !== undefined || responsesUsage.output_tokens !== undefined)
) {
return {
prompt_tokens: responsesUsage.input_tokens || 0,
completion_tokens: responsesUsage.output_tokens || 0,
cached_tokens: responsesUsage.cache_read_input_tokens,
cache_creation_input_tokens: responsesUsage.cache_creation_input_tokens,
reasoning_tokens:
responsesUsage.reasoning_tokens || responsesUsage.output_tokens_details?.reasoning_tokens,
};
}
// Claude format
if (
responseBody.usage &&
typeof responseBody.usage === "object" &&
(responseBody.usage.input_tokens !== undefined ||
responseBody.usage.output_tokens !== undefined)
) {
return {
prompt_tokens: responseBody.usage.input_tokens || 0,
completion_tokens: responseBody.usage.output_tokens || 0,
cache_read_input_tokens: responseBody.usage.cache_read_input_tokens,
cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens,
};
}
// Gemini format
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") {
return {
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount,
};
}
return null;
}

110
open-sse/index.js Normal file
View File

@@ -0,0 +1,110 @@
// Patch global fetch with proxy support (must be first)
import "./utils/proxyFetch.js";
// Config
export {
PROVIDERS,
OAUTH_ENDPOINTS,
CACHE_TTL,
DEFAULT_MAX_TOKENS,
CLAUDE_SYSTEM_PROMPT,
COOLDOWN_MS,
BACKOFF_CONFIG,
} from "./config/constants.js";
export {
PROVIDER_MODELS,
getProviderModels,
getDefaultModel,
isValidModel,
findModelName,
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
getModelsByProviderId,
} from "./config/providerModels.js";
// Translator
export { FORMATS } from "./translator/formats.js";
export {
register,
translateRequest,
translateResponse,
needsTranslation,
initState,
initTranslators,
} from "./translator/index.js";
// Services
export {
detectFormat,
getProviderConfig,
buildProviderUrl,
buildProviderHeaders,
getTargetFormat,
} from "./services/provider.js";
export { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "./services/model.js";
export {
checkFallbackError,
isAccountUnavailable,
getUnavailableUntil,
filterAvailableAccounts,
} from "./services/accountFallback.js";
export {
TOKEN_EXPIRY_BUFFER_MS,
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshIflowToken,
refreshGitHubToken,
refreshCopilotToken,
getAccessToken,
refreshTokenByProvider,
} from "./services/tokenRefresh.js";
// Handlers
export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js";
export {
createStreamController,
pipeWithDisconnect,
createDisconnectAwareStream,
} from "./utils/streamHandler.js";
// Executors
export { getExecutor, hasSpecializedExecutor } from "./executors/index.js";
// Utils
export { errorResponse, formatProviderError } from "./utils/error.js";
export {
createSSETransformStreamWithLogger,
createPassthroughStreamWithLogger,
} from "./utils/stream.js";
// Embeddings
export { handleEmbedding } from "./handlers/embeddings.js";
export {
EMBEDDING_PROVIDERS,
getEmbeddingProvider,
parseEmbeddingModel,
getAllEmbeddingModels,
} from "./config/embeddingRegistry.js";
// Image Generation
export { handleImageGeneration } from "./handlers/imageGeneration.js";
export {
IMAGE_PROVIDERS,
getImageProvider,
parseImageModel,
getAllImageModels,
} from "./config/imageRegistry.js";
// Think Tag Parser
export {
hasThinkTags,
extractThinkTags,
processStreamingThinkDelta,
flushThinkBuffer,
} from "./utils/thinkTagParser.js";

13
open-sse/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@omniroute/open-sse",
"version": "1.0.0",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
"types": "types.d.ts",
"private": true,
"exports": {
".": "./index.js",
"./*": "./*"
}
}

View File

@@ -0,0 +1,201 @@
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/constants.js";
/**
* Calculate exponential backoff cooldown for rate limits (429)
* Level 0: 1s, Level 1: 2s, Level 2: 4s... → max 2 min
* @param {number} backoffLevel - Current backoff level
* @returns {number} Cooldown in milliseconds
*/
export function getQuotaCooldown(backoffLevel = 0) {
const cooldown = BACKOFF_CONFIG.base * Math.pow(2, backoffLevel);
return Math.min(cooldown, BACKOFF_CONFIG.max);
}
/**
* Check if error should trigger account fallback (switch to next account)
* @param {number} status - HTTP status code
* @param {string} errorText - Error message text
* @param {number} backoffLevel - Current backoff level for exponential backoff
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
*/
export function checkFallbackError(status, errorText, backoffLevel = 0) {
// Check error message FIRST - specific patterns take priority over status codes
if (errorText) {
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
const lowerError = errorStr.toLowerCase();
if (lowerError.includes("no credentials")) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
}
if (lowerError.includes("request not allowed")) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed };
}
// Rate limit keywords - exponential backoff
if (
lowerError.includes("rate limit") ||
lowerError.includes("too many requests") ||
lowerError.includes("quota exceeded") ||
lowerError.includes("capacity") ||
lowerError.includes("overloaded")
) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return {
shouldFallback: true,
cooldownMs: getQuotaCooldown(backoffLevel),
newBackoffLevel: newLevel,
};
}
}
if (status === HTTP_STATUS.UNAUTHORIZED) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized };
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired };
}
if (status === HTTP_STATUS.NOT_FOUND) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
}
// 429 - Rate limit with exponential backoff
if (status === HTTP_STATUS.RATE_LIMITED) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return {
shouldFallback: true,
cooldownMs: getQuotaCooldown(backoffLevel),
newBackoffLevel: newLevel,
};
}
// Transient errors
const transientStatuses = [
HTTP_STATUS.NOT_ACCEPTABLE,
HTTP_STATUS.REQUEST_TIMEOUT,
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
];
if (transientStatuses.includes(status)) {
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
}
// 400 Bad Request - don't fallback (same request will fail on all accounts)
if (status === HTTP_STATUS.BAD_REQUEST) {
return { shouldFallback: false, cooldownMs: 0 };
}
// All other errors - fallback with transient cooldown
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
}
/**
* Check if account is currently unavailable (cooldown not expired)
*/
export function isAccountUnavailable(unavailableUntil) {
if (!unavailableUntil) return false;
return new Date(unavailableUntil).getTime() > Date.now();
}
/**
* Calculate unavailable until timestamp
*/
export function getUnavailableUntil(cooldownMs) {
return new Date(Date.now() + cooldownMs).toISOString();
}
/**
* Get the earliest rateLimitedUntil from a list of accounts
* @param {Array} accounts - Array of account objects with rateLimitedUntil
* @returns {string|null} Earliest rateLimitedUntil ISO string, or null
*/
export function getEarliestRateLimitedUntil(accounts) {
let earliest = null;
const now = Date.now();
for (const acc of accounts) {
if (!acc.rateLimitedUntil) continue;
const until = new Date(acc.rateLimitedUntil).getTime();
if (until <= now) continue;
if (!earliest || until < earliest) earliest = until;
}
if (!earliest) return null;
return new Date(earliest).toISOString();
}
/**
* Format rateLimitedUntil to human-readable "reset after Xm Ys"
* @param {string} rateLimitedUntil - ISO timestamp
* @returns {string} e.g. "reset after 2m 30s"
*/
export function formatRetryAfter(rateLimitedUntil) {
if (!rateLimitedUntil) return "";
const diffMs = new Date(rateLimitedUntil).getTime() - Date.now();
if (diffMs <= 0) return "reset after 0s";
const totalSec = Math.ceil(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const parts = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
return `reset after ${parts.join(" ")}`;
}
/**
* Filter available accounts (not in cooldown)
*/
export function filterAvailableAccounts(accounts, excludeId = null) {
const now = Date.now();
return accounts.filter((acc) => {
if (excludeId && acc.id === excludeId) return false;
if (acc.rateLimitedUntil) {
const until = new Date(acc.rateLimitedUntil).getTime();
if (until > now) return false;
}
return true;
});
}
/**
* Reset account state when request succeeds
* Clears cooldown and resets backoff level to 0
* @param {object} account - Account object
* @returns {object} Updated account with reset state
*/
export function resetAccountState(account) {
if (!account) return account;
return {
...account,
rateLimitedUntil: null,
backoffLevel: 0,
lastError: null,
status: "active",
};
}
/**
* Apply error state to account
* @param {object} account - Account object
* @param {number} status - HTTP status code
* @param {string} errorText - Error message
* @returns {object} Updated account with error state
*/
export function applyErrorState(account, status, errorText) {
if (!account) return account;
const backoffLevel = account.backoffLevel || 0;
const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
return {
...account,
rateLimitedUntil: cooldownMs > 0 ? getUnavailableUntil(cooldownMs) : null,
backoffLevel: newBackoffLevel ?? backoffLevel,
lastError: { status, message: errorText, timestamp: new Date().toISOString() },
status: "error",
};
}

568
open-sse/services/combo.js Normal file
View File

@@ -0,0 +1,568 @@
/**
* Shared combo (model combo) handling with fallback support
* Supports: priority (sequential), weighted (probabilistic), and round-robin (circular) strategies
*/
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
import { unavailableResponse } from "../utils/error.js";
import { recordComboRequest } from "./comboMetrics.js";
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.js";
import * as semaphore from "./rateLimitSemaphore.js";
const MAX_COMBO_DEPTH = 3;
// In-memory atomic counter per combo for round-robin distribution
// Resets on server restart (by design — no stale state)
const rrCounters = new Map();
/**
* Normalize a model entry to { model, weight }
* Supports both legacy string format and new object format
*/
function normalizeModelEntry(entry) {
if (typeof entry === "string") return { model: entry, weight: 0 };
return { model: entry.model, weight: entry.weight || 0 };
}
/**
* Get combo models from combos data (for open-sse standalone use)
* @param {string} modelStr - Model string to check
* @param {Array|Object} combosData - Array of combos or object with combos
* @returns {Object|null} Full combo object or null if not a combo
*/
export function getComboFromData(modelStr, combosData) {
const combos = Array.isArray(combosData) ? combosData : combosData?.combos || [];
const combo = combos.find((c) => c.name === modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}
return null;
}
/**
* Legacy: Get combo models as string array (backward compat)
*/
export function getComboModelsFromData(modelStr, combosData) {
const combo = getComboFromData(modelStr, combosData);
if (!combo) return null;
return combo.models.map((m) => normalizeModelEntry(m).model);
}
/**
* Validate combo DAG — detect circular references and enforce max depth
* @param {string} comboName - Name of the combo to validate
* @param {Array} allCombos - All combos in the system
* @param {Set} [visited] - Set of already visited combo names (for cycle detection)
* @param {number} [depth] - Current depth level
* @throws {Error} If circular reference or max depth exceeded
*/
export function validateComboDAG(comboName, allCombos, visited = new Set(), depth = 0) {
if (depth > MAX_COMBO_DEPTH) {
throw new Error(`Max combo nesting depth (${MAX_COMBO_DEPTH}) exceeded at "${comboName}"`);
}
if (visited.has(comboName)) {
throw new Error(`Circular combo reference detected: ${comboName}`);
}
visited.add(comboName);
const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || [];
const combo = combos.find((c) => c.name === comboName);
if (!combo || !combo.models) return;
for (const entry of combo.models) {
const modelName = normalizeModelEntry(entry).model;
// Check if this model name is itself a combo (not a provider/model pattern)
const nestedCombo = combos.find((c) => c.name === modelName);
if (nestedCombo) {
validateComboDAG(modelName, combos, new Set(visited), depth + 1);
}
}
}
/**
* Resolve nested combos by expanding inline to a flat model list
* Respects max depth and detects cycles
* @param {Object} combo - The combo object
* @param {Array} allCombos - All combos in the system
* @param {Set} [visited] - For cycle detection
* @param {number} [depth] - Current depth
* @returns {Array} Flat array of model strings
*/
export function resolveNestedComboModels(combo, allCombos, visited = new Set(), depth = 0) {
if (depth > MAX_COMBO_DEPTH) return combo.models.map((m) => normalizeModelEntry(m).model);
if (visited.has(combo.name)) return []; // cycle safety
visited.add(combo.name);
const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || [];
const resolved = [];
for (const entry of combo.models || []) {
const modelName = normalizeModelEntry(entry).model;
const nestedCombo = combos.find((c) => c.name === modelName);
if (nestedCombo) {
// Recursively expand the nested combo
const nested = resolveNestedComboModels(nestedCombo, combos, new Set(visited), depth + 1);
resolved.push(...nested);
} else {
resolved.push(modelName);
}
}
return resolved;
}
/**
* Select a model using weighted random distribution
* @param {Array} models - Array of { model, weight } entries
* @returns {string} Selected model string
*/
function selectWeightedModel(models) {
const entries = models.map((m) => normalizeModelEntry(m));
const totalWeight = entries.reduce((sum, m) => sum + m.weight, 0);
if (totalWeight <= 0) {
// All weights are 0 → uniform random
return entries[Math.floor(Math.random() * entries.length)].model;
}
let random = Math.random() * totalWeight;
for (const entry of entries) {
random -= entry.weight;
if (random <= 0) return entry.model;
}
return entries[entries.length - 1].model; // safety fallback
}
/**
* Order models for weighted fallback (selected first, then by descending weight)
*/
function orderModelsForWeightedFallback(models, selectedModel) {
const entries = models.map((m) => normalizeModelEntry(m));
const selected = entries.find((e) => e.model === selectedModel);
const rest = entries.filter((e) => e.model !== selectedModel).sort((a, b) => b.weight - a.weight); // highest weight first for fallback
return [selected, ...rest].filter(Boolean).map((e) => e.model);
}
/**
* Handle combo chat with fallback
* Supports priority (sequential) and weighted (probabilistic) strategies
* @param {Object} options
* @param {Object} options.body - Request body
* @param {Object} options.combo - Full combo object { name, models, strategy, config }
* @param {Function} options.handleSingleModel - Function: (body, modelStr) => Promise<Response>
* @param {Function} [options.isModelAvailable] - Optional pre-check: (modelStr) => Promise<boolean>
* @param {Object} options.log - Logger object
* @returns {Promise<Response>}
*/
export async function handleComboChat({
body,
combo,
handleSingleModel,
isModelAvailable,
log,
settings,
allCombos,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
// Route to round-robin handler if strategy matches
if (strategy === "round-robin") {
return handleRoundRobinCombo({
body,
combo,
handleSingleModel,
isModelAvailable,
log,
settings,
allCombos,
});
}
// Use config cascade if settings provided
const config = settings
? resolveComboConfig(combo, settings)
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
const maxRetries = config.maxRetries ?? 1;
const retryDelayMs = config.retryDelayMs ?? 2000;
let orderedModels;
// Resolve nested combos if allCombos provided
if (allCombos) {
const flatModels = resolveNestedComboModels(combo, allCombos);
if (strategy === "weighted") {
// For weighted + nested, select from original models then fallback sequentially
const selected = selectWeightedModel(models);
orderedModels = orderModelsForWeightedFallback(models, selected);
// But if any were nested, they are already resolved to flat
orderedModels = orderedModels.flatMap((m) => {
const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || [];
const nested = combos.find((c) => c.name === m);
if (nested) return resolveNestedComboModels(nested, allCombos);
return [m];
});
log.info(
"COMBO",
`Weighted selection with nested resolution: ${orderedModels.length} total models`
);
} else {
orderedModels = flatModels;
log.info("COMBO", `Priority with nested resolution: ${orderedModels.length} total models`);
}
} else if (strategy === "weighted") {
const selected = selectWeightedModel(models);
orderedModels = orderModelsForWeightedFallback(models, selected);
log.info("COMBO", `Weighted selection: ${selected} (from ${models.length} models)`);
} else {
orderedModels = models.map((m) => normalizeModelEntry(m).model);
}
let lastError = null;
let earliestRetryAfter = null;
let lastStatus = null;
const startTime = Date.now();
let resolvedByModel = null;
let fallbackCount = 0;
for (let i = 0; i < orderedModels.length; i++) {
const modelStr = orderedModels[i];
// Pre-check: skip models where all accounts are in cooldown
if (isModelAvailable) {
const available = await isModelAvailable(modelStr);
if (!available) {
log.info("COMBO", `Skipping ${modelStr} (all accounts in cooldown)`);
if (i > 0) fallbackCount++;
continue;
}
}
// Retry loop for transient errors
for (let retry = 0; retry <= maxRetries; retry++) {
if (retry > 0) {
log.info(
"COMBO",
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
);
await new Promise((r) => setTimeout(r, retryDelayMs));
}
log.info(
"COMBO",
`Trying model ${i + 1}/${orderedModels.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModel(body, modelStr);
// Success — return response
if (result.ok) {
resolvedByModel = modelStr;
const latencyMs = Date.now() - startTime;
log.info(
"COMBO",
`Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
fallbackCount,
strategy,
});
return result;
}
// Extract error info from response
let errorText = result.statusText || "";
let retryAfter = null;
try {
const cloned = result.clone();
try {
const errorBody = await cloned.json();
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
} catch {
try {
const text = await result.text();
if (text) errorText = text.substring(0, 500);
} catch {
/* Body consumed */
}
}
} catch {
/* Clone failed */
}
// Track earliest retryAfter
if (
retryAfter &&
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
) {
earliestRetryAfter = retryAfter;
}
// Normalize error text
if (typeof errorText !== "string") {
try {
errorText = JSON.stringify(errorText);
} catch {
errorText = String(errorText);
}
}
const { shouldFallback } = checkFallbackError(result.status, errorText);
if (!shouldFallback) {
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
return result;
}
// Check if this is a transient error worth retrying on same model
const isTransient = [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
continue; // Retry same model
}
// Done retrying this model
lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status;
if (i > 0) fallbackCount++;
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
break; // Move to next model
}
}
// All models failed
const latencyMs = Date.now() - startTime;
recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy });
const status = lastStatus || 406;
const msg = lastError || "All combo models unavailable";
if (earliestRetryAfter) {
const retryHuman = formatRetryAfter(earliestRetryAfter);
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
}
log.warn("COMBO", `All models failed | ${msg}`);
return new Response(JSON.stringify({ error: { message: msg } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
/**
* Handle round-robin combo: each request goes to the next model in circular order.
* Uses semaphore-based concurrency control with queue + rate-limit awareness.
*
* Flow:
* 1. Pick target model via atomic counter (counter % models.length)
* 2. Acquire semaphore slot (may queue if at max concurrency)
* 3. Send request to target model
* 4. On 429 → mark model rate-limited, try next model in rotation
* 5. On semaphore timeout → fallback to next available model
*/
async function handleRoundRobinCombo({
body,
combo,
handleSingleModel,
isModelAvailable,
log,
settings,
allCombos,
}) {
const models = combo.models || [];
const config = settings
? resolveComboConfig(combo, settings)
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
const concurrency = config.concurrencyPerModel ?? 3;
const queueTimeout = config.queueTimeoutMs ?? 30000;
const maxRetries = config.maxRetries ?? 1;
const retryDelayMs = config.retryDelayMs ?? 2000;
// Resolve models (support nested combos)
let orderedModels;
if (allCombos) {
orderedModels = resolveNestedComboModels(combo, allCombos);
} else {
orderedModels = models.map((m) => normalizeModelEntry(m).model);
}
const modelCount = orderedModels.length;
if (modelCount === 0) {
return unavailableResponse(406, "Round-robin combo has no models");
}
// Get and increment atomic counter
const counter = rrCounters.get(combo.name) || 0;
rrCounters.set(combo.name, counter + 1);
const startIndex = counter % modelCount;
const startTime = Date.now();
let lastError = null;
let lastStatus = null;
let earliestRetryAfter = null;
let fallbackCount = 0;
// Try each model starting from the round-robin target
for (let offset = 0; offset < modelCount; offset++) {
const modelIndex = (startIndex + offset) % modelCount;
const modelStr = orderedModels[modelIndex];
// Pre-check availability
if (isModelAvailable) {
const available = await isModelAvailable(modelStr);
if (!available) {
log.info("COMBO-RR", `Skipping ${modelStr} (all accounts in cooldown)`);
if (offset > 0) fallbackCount++;
continue;
}
}
// Acquire semaphore slot (may wait in queue)
let release;
try {
release = await semaphore.acquire(modelStr, {
maxConcurrency: concurrency,
timeoutMs: queueTimeout,
});
} catch (err) {
if (err.code === "SEMAPHORE_TIMEOUT") {
log.warn("COMBO-RR", `Semaphore timeout for ${modelStr}, trying next model`);
if (offset > 0) fallbackCount++;
continue;
}
throw err;
}
// Retry loop within this model
try {
for (let retry = 0; retry <= maxRetries; retry++) {
if (retry > 0) {
log.info(
"COMBO-RR",
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
);
await new Promise((r) => setTimeout(r, retryDelayMs));
}
log.info(
"COMBO-RR",
`[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModel(body, modelStr);
// Success
if (result.ok) {
const latencyMs = Date.now() - startTime;
log.info(
"COMBO-RR",
`${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
);
recordComboRequest(combo.name, modelStr, {
success: true,
latencyMs,
fallbackCount,
strategy: "round-robin",
});
return result;
}
// Extract error info
let errorText = result.statusText || "";
let retryAfter = null;
try {
const cloned = result.clone();
try {
const errorBody = await cloned.json();
errorText =
errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
retryAfter = errorBody?.retryAfter || null;
} catch {
try {
const text = await result.text();
if (text) errorText = text.substring(0, 500);
} catch {
/* Body consumed */
}
}
} catch {
/* Clone failed */
}
if (
retryAfter &&
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
) {
earliestRetryAfter = retryAfter;
}
if (typeof errorText !== "string") {
try {
errorText = JSON.stringify(errorText);
} catch {
errorText = String(errorText);
}
}
const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText);
// Rate-limited → mark in semaphore so queue pauses
if (result.status === 429 && cooldownMs > 0) {
semaphore.markRateLimited(modelStr, cooldownMs);
log.warn("COMBO-RR", `${modelStr} rate-limited, cooldown ${cooldownMs}ms`);
}
if (!shouldFallback) {
log.warn("COMBO-RR", `${modelStr} failed (no fallback)`, { status: result.status });
return result;
}
// Transient error → retry same model
const isTransient = [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
continue;
}
// Done with this model
lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status;
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
break;
}
} finally {
// ALWAYS release semaphore slot
release();
}
}
// All models exhausted
const latencyMs = Date.now() - startTime;
recordComboRequest(combo.name, null, {
success: false,
latencyMs,
fallbackCount,
strategy: "round-robin",
});
const status = lastStatus || 406;
const msg = lastError || "All round-robin combo models unavailable";
if (earliestRetryAfter) {
const retryHuman = formatRetryAfter(earliestRetryAfter);
log.warn("COMBO-RR", `All models failed | ${msg} (${retryHuman})`);
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
}
log.warn("COMBO-RR", `All models failed | ${msg}`);
return new Response(JSON.stringify({ error: { message: msg } }), {
status,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -0,0 +1,52 @@
/**
* Combo Configuration Resolver
*
* Implements 3-layer cascade: Global Defaults → Provider Overrides → Per-Combo Config
* Most specific wins.
*/
const DEFAULT_COMBO_CONFIG = {
strategy: "priority",
maxRetries: 1,
retryDelayMs: 2000,
timeoutMs: 120000,
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
healthCheckEnabled: true,
healthCheckTimeoutMs: 3000,
maxComboDepth: 3,
trackMetrics: true,
};
/**
* Resolve effective config for a combo, applying cascade:
* DEFAULT_COMBO_CONFIG → settings.comboDefaults → settings.providerOverrides[provider] → combo.config
*
* @param {Object} combo - The combo object { config, ... }
* @param {Object} settings - App settings from localDb
* @param {string} [provider] - Optional provider to apply provider-level overrides
* @returns {Object} Resolved config
*/
export function resolveComboConfig(combo, settings, provider) {
const global = settings?.comboDefaults || {};
const providerOverride = provider ? settings?.providerOverrides?.[provider] || {} : {};
const comboConfig = combo?.config || {};
// Clean undefined values before spreading
const clean = (obj) =>
Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null));
return {
...DEFAULT_COMBO_CONFIG,
...clean(global),
...clean(providerOverride),
...clean(comboConfig),
};
}
/**
* Get the default combo config (used when no overrides exist)
*/
export function getDefaultComboConfig() {
return { ...DEFAULT_COMBO_CONFIG };
}

View File

@@ -0,0 +1,132 @@
/**
* In-memory combo metrics tracker
* Tracks per-combo and per-model request counts, latency, success/failure rates
* Provides API for reading metrics from the dashboard
*/
// In-memory store
const metrics = new Map();
/**
* Record a combo request result
* @param {string} comboName
* @param {string} modelStr - The model that handled the request (or null if all failed)
* @param {Object} options
* @param {boolean} options.success
* @param {number} options.latencyMs
* @param {number} options.fallbackCount - How many fallbacks occurred
* @param {string} [options.strategy] - "priority" or "weighted"
*/
export function recordComboRequest(
comboName,
modelStr,
{ success, latencyMs, fallbackCount = 0, strategy = "priority" }
) {
if (!metrics.has(comboName)) {
metrics.set(comboName, {
totalRequests: 0,
totalSuccesses: 0,
totalFailures: 0,
totalFallbacks: 0,
totalLatencyMs: 0,
strategy,
lastUsedAt: null,
byModel: {},
});
}
const combo = metrics.get(comboName);
combo.totalRequests++;
combo.totalLatencyMs += latencyMs;
combo.totalFallbacks += fallbackCount;
combo.lastUsedAt = new Date().toISOString();
combo.strategy = strategy;
if (success) {
combo.totalSuccesses++;
} else {
combo.totalFailures++;
}
// Per-model tracking
if (modelStr) {
if (!combo.byModel[modelStr]) {
combo.byModel[modelStr] = {
requests: 0,
successes: 0,
failures: 0,
totalLatencyMs: 0,
lastStatus: null,
lastUsedAt: null,
};
}
const modelMetric = combo.byModel[modelStr];
modelMetric.requests++;
modelMetric.totalLatencyMs += latencyMs;
modelMetric.lastUsedAt = new Date().toISOString();
if (success) {
modelMetric.successes++;
modelMetric.lastStatus = "ok";
} else {
modelMetric.failures++;
modelMetric.lastStatus = "error";
}
}
}
/**
* Get metrics for a specific combo
* @param {string} comboName
* @returns {Object|null}
*/
export function getComboMetrics(comboName) {
const combo = metrics.get(comboName);
if (!combo) return null;
return {
...combo,
avgLatencyMs:
combo.totalRequests > 0 ? Math.round(combo.totalLatencyMs / combo.totalRequests) : 0,
successRate:
combo.totalRequests > 0 ? Math.round((combo.totalSuccesses / combo.totalRequests) * 100) : 0,
fallbackRate:
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
byModel: Object.fromEntries(
Object.entries(combo.byModel).map(([model, m]) => [
model,
{
...m,
avgLatencyMs: m.requests > 0 ? Math.round(m.totalLatencyMs / m.requests) : 0,
successRate: m.requests > 0 ? Math.round((m.successes / m.requests) * 100) : 0,
},
])
),
};
}
/**
* Get metrics for all combos
* @returns {Object} Map of comboName → metrics
*/
export function getAllComboMetrics() {
const result = {};
for (const [name] of metrics) {
result[name] = getComboMetrics(name);
}
return result;
}
/**
* Reset metrics for a specific combo
*/
export function resetComboMetrics(comboName) {
metrics.delete(comboName);
}
/**
* Reset all combo metrics
*/
export function resetAllComboMetrics() {
metrics.clear();
}

188
open-sse/services/model.js Normal file
View File

@@ -0,0 +1,188 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.js";
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
const ALIAS_TO_PROVIDER_ID = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.
const PROVIDER_MODEL_ALIASES = {
github: {
"claude-4.5-opus": "claude-opus-4-5-20251101",
"claude-opus-4.5": "claude-opus-4-5-20251101",
"gemini-3-pro": "gemini-3-pro-preview",
"gemini-3-flash": "gemini-3-flash-preview",
"raptor-mini": "oswe-vscode-prime",
},
antigravity: {
"gemini-3-flash": "gemini-3-flash-preview",
},
};
// Reverse index: modelId -> providerIds that expose this model
const MODEL_TO_PROVIDERS = new Map();
for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
const providerId = ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
for (const modelEntry of models || []) {
const modelId = modelEntry?.id;
if (!modelId) continue;
const providers = MODEL_TO_PROVIDERS.get(modelId) || [];
if (!providers.includes(providerId)) {
providers.push(providerId);
MODEL_TO_PROVIDERS.set(modelId, providers);
}
}
}
/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId) {
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
}
/**
* Resolve provider-specific legacy model alias to canonical model ID.
*/
function resolveProviderModelAlias(providerOrAlias, modelId) {
if (!modelId || typeof modelId !== "string") return modelId;
const providerId = resolveProviderAlias(providerOrAlias);
const aliases = PROVIDER_MODEL_ALIASES[providerId];
return aliases?.[modelId] || modelId;
}
/**
* Parse model string: "alias/model" or "provider/model" or just alias
*/
export function parseModel(modelStr) {
if (!modelStr) {
return { provider: null, model: null, isAlias: false, providerAlias: null };
}
// Sanitize: reject strings with path traversal or control characters
if (/\.\.[\/\\]/.test(modelStr) || /[\x00-\x1f]/.test(modelStr)) {
console.log(`[MODEL] Warning: rejected malformed model string: "${modelStr.substring(0, 50)}"`);
return { provider: null, model: null, isAlias: false, providerAlias: null };
}
// Check if standard format: provider/model or alias/model
if (modelStr.includes("/")) {
const firstSlash = modelStr.indexOf("/");
const providerOrAlias = modelStr.slice(0, firstSlash);
const model = modelStr.slice(firstSlash + 1);
const provider = resolveProviderAlias(providerOrAlias);
return { provider, model, isAlias: false, providerAlias: providerOrAlias };
}
// Alias format (model alias, not provider alias)
return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
}
/**
* Resolve model alias from aliases object
* Format: { "alias": "provider/model" }
*/
export function resolveModelAliasFromMap(alias, aliases) {
if (!aliases) return null;
// Check if alias exists
const resolved = aliases[alias];
if (!resolved) return null;
// Resolved value is "provider/model" format
if (typeof resolved === "string" && resolved.includes("/")) {
const firstSlash = resolved.indexOf("/");
const providerOrAlias = resolved.slice(0, firstSlash);
return {
provider: resolveProviderAlias(providerOrAlias),
model: resolved.slice(firstSlash + 1),
};
}
// Or object { provider, model }
if (typeof resolved === "object" && resolved.provider && resolved.model) {
return {
provider: resolveProviderAlias(resolved.provider),
model: resolved.model,
};
}
return null;
}
/**
* Get full model info (parse or resolve)
* @param {string} modelStr - Model string
* @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases
*/
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
const parsed = parseModel(modelStr);
if (!parsed.isAlias) {
const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model);
return {
provider: parsed.provider,
model: canonicalModel,
};
}
// Get aliases (from object or function)
const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter;
// Resolve alias
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
if (resolved) {
const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model);
return {
provider: resolved.provider,
model: canonicalModel,
};
}
const modelId = parsed.model;
const providers = MODEL_TO_PROVIDERS.get(modelId) || [];
// Preserve historical behavior: OpenAI stays default when model exists there
if (providers.includes("openai")) {
return {
provider: "openai",
model: modelId,
};
}
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
if (nonOpenAIProviders.length === 1) {
const provider = nonOpenAIProviders[0];
const canonicalModel = resolveProviderModelAlias(provider, modelId);
return { provider, model: canonicalModel };
}
if (nonOpenAIProviders.length > 1) {
const aliasesForHint = nonOpenAIProviders.map((p) => PROVIDER_ID_TO_ALIAS[p] || p);
const hints = aliasesForHint.slice(0, 2).map((alias) => `${alias}/${modelId}`);
const message = `Ambiguous model '${modelId}'. Use provider/model prefix (ex: ${hints.join(" or ")}).`;
console.warn(`[MODEL] ${message} Candidates: ${aliasesForHint.join(", ")}`);
return {
provider: null,
model: modelId,
errorType: "ambiguous_model",
errorMessage: message,
candidateProviders: nonOpenAIProviders,
candidateAliases: aliasesForHint,
};
}
// Fallback: treat as openai model
return {
provider: "openai",
model: modelId,
};
}

View File

@@ -0,0 +1,297 @@
import { PROVIDERS } from "../config/constants.js";
import { getRegistryEntry } from "../config/providerRegistry.js";
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
};
const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
function isOpenAICompatible(provider) {
return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX);
}
function isAnthropicCompatible(provider) {
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
function getOpenAICompatibleType(provider) {
if (!isOpenAICompatible(provider)) return "chat";
return provider.includes("responses") ? "responses" : "chat";
}
function buildOpenAICompatibleUrl(baseUrl, apiType) {
const normalized = baseUrl.replace(/\/$/, "");
const path = apiType === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
function buildAnthropicCompatibleUrl(baseUrl) {
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`;
}
// Detect request format from body structure
export function detectFormat(body) {
// OpenAI Responses API:
// - input can be string, array, or object (not only array)
// - some clients send responses-specific fields even when input is omitted
const hasInputField =
Object.prototype.hasOwnProperty.call(body, "input") && body.input !== undefined;
const hasResponsesSpecificFields =
body.max_output_tokens !== undefined ||
body.previous_response_id !== undefined ||
body.reasoning !== undefined;
if (hasInputField || hasResponsesSpecificFields) {
return "openai-responses";
}
// Antigravity format: Gemini wrapped in body.request
if (body.request?.contents && body.userAgent === "antigravity") {
return "antigravity";
}
// Gemini format: has contents array
if (body.contents && Array.isArray(body.contents)) {
return "gemini";
}
// OpenAI-specific indicators (check BEFORE Claude)
// These fields are OpenAI-specific and never appear in Claude format
if (
body.stream_options || // OpenAI streaming options
body.response_format || // JSON mode, etc.
body.logprobs !== undefined || // Log probabilities
body.top_logprobs !== undefined ||
body.n !== undefined || // Number of completions
body.presence_penalty !== undefined || // Penalties
body.frequency_penalty !== undefined ||
body.logit_bias || // Token biasing
body.user // User identifier
) {
return "openai";
}
// Claude format: messages with content as array of objects with type
// Claude requires content to be array with specific structure
if (body.messages && Array.isArray(body.messages)) {
const firstMsg = body.messages[0];
// If content is array, check if it follows Claude structure
if (firstMsg?.content && Array.isArray(firstMsg.content)) {
const firstContent = firstMsg.content[0];
// Claude format has specific types: text, image, tool_use, tool_result
// OpenAI multimodal has: text, image_url (note the difference)
if (firstContent?.type === "text" && !body.model?.includes("/")) {
// Could be Claude or OpenAI multimodal
// Check for Claude-specific fields
if (body.system || body.anthropic_version) {
return "claude";
}
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
const hasClaudeImage = firstMsg.content.some(
(c) => c.type === "image" && c.source?.type === "base64"
);
const hasOpenAIImage = firstMsg.content.some(
(c) => c.type === "image_url" && c.image_url?.url
);
if (hasClaudeImage) return "claude";
if (hasOpenAIImage) return "openai";
// If still unclear, check for tool format
const hasClaudeTool = firstMsg.content.some(
(c) => c.type === "tool_use" || c.type === "tool_result"
);
if (hasClaudeTool) return "claude";
}
}
// If content is string, it's likely OpenAI (Claude also supports this)
// Check for other Claude-specific indicators
if (body.system !== undefined || body.anthropic_version) {
return "claude";
}
// Additional Claude heuristic: max_tokens is a required Claude field
// and Claude requests rarely include OpenAI-specific fields like
// stream_options, response_format, or logprobs
if (body.max_tokens && !body.stream_options && !body.response_format) {
return "claude";
}
}
// Default to OpenAI format
return "openai";
}
// Get provider config
export function getProviderConfig(provider) {
if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider);
return {
...PROVIDERS.openai,
format: apiType === "responses" ? "openai-responses" : "openai",
baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl,
};
}
if (isAnthropicCompatible(provider)) {
return {
...PROVIDERS.anthropic, // Use Anthropic defaults (header: x-api-key)
format: "claude",
baseUrl: ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl,
};
}
return PROVIDERS[provider] || PROVIDERS.openai;
}
// Get number of fallback URLs for provider (for retry logic)
export function getProviderFallbackCount(provider) {
const config = getProviderConfig(provider);
return config.baseUrls?.length || 1;
}
// Build provider URL
export function buildProviderUrl(provider, model, stream = true, options = {}) {
if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider);
const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
return buildOpenAICompatibleUrl(baseUrl, apiType);
}
if (isAnthropicCompatible(provider)) {
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
return buildAnthropicCompatibleUrl(baseUrl);
}
const entry = getRegistryEntry(provider);
const config = getProviderConfig(provider);
// Registry-driven URL building
if (entry) {
// Multi-URL providers (e.g. antigravity)
if (entry.baseUrls) {
const urlIndex = options?.baseUrlIndex || 0;
const baseUrl = entry.baseUrls[urlIndex] || entry.baseUrls[0];
if (entry.urlBuilder) return entry.urlBuilder(baseUrl, model, stream);
return baseUrl;
}
// Custom URL builder (e.g. gemini, gemini-cli)
if (entry.urlBuilder) {
return entry.urlBuilder(entry.baseUrl, model, stream);
}
// URL suffix (e.g. claude: ?beta=true)
if (entry.urlSuffix) {
return `${entry.baseUrl}${entry.urlSuffix}`;
}
}
return config.baseUrl;
}
// Build provider headers
export function buildProviderHeaders(provider, credentials, stream = true, body = null) {
const config = getProviderConfig(provider);
const entry = getRegistryEntry(provider);
const headers = {
"Content-Type": "application/json",
...config.headers,
};
// Add auth header
// Specific override for Anthropic Compatible
if (isAnthropicCompatible(provider)) {
if (credentials.apiKey) {
headers["x-api-key"] = credentials.apiKey;
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
if (!headers["anthropic-version"]) {
headers["anthropic-version"] = "2023-06-01";
}
} else if (provider === "github") {
// GitHub Copilot requires special dynamic headers (x-request-id)
const githubToken = credentials.copilotToken || credentials.accessToken;
headers["Authorization"] = `Bearer ${githubToken}`;
headers["x-request-id"] = crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
if (!stream) {
headers["Accept"] = "application/json";
}
} else if (entry) {
// Registry-driven auth
const authHeader = entry.authHeader || "bearer";
if (authHeader === "x-api-key") {
const token = credentials.apiKey || credentials.accessToken;
if (token) {
headers["x-api-key"] = token;
}
} else if (authHeader === "x-goog-api-key") {
if (credentials.apiKey) {
headers["x-goog-api-key"] = credentials.apiKey;
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
} else {
// bearer (default)
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}
} else {
// Fallback for unknown providers
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}
// Stream accept header
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
// Get target format for provider
export function getTargetFormat(provider) {
if (isOpenAICompatible(provider)) {
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
}
if (isAnthropicCompatible(provider)) {
return "claude";
}
// Registry-driven format lookup
const entry = getRegistryEntry(provider);
if (entry) return entry.format || "openai";
const config = getProviderConfig(provider);
return config.format || "openai";
}
// Check if last message is from user
export function isLastMessageFromUser(body) {
const messages = body.messages || body.contents;
if (!messages?.length) return true;
const lastMsg = messages[messages.length - 1];
return lastMsg?.role === "user";
}
// Check if request has thinking config
export function hasThinkingConfig(body) {
return !!(body.reasoning_effort || body.thinking?.type === "enabled");
}
// Normalize thinking config based on last message role
// - If lastMessage is not user → remove thinking config
// - If lastMessage is user AND has thinking config → keep it (force enable)
export function normalizeThinkingConfig(body) {
if (!isLastMessageFromUser(body)) {
delete body.reasoning_effort;
delete body.thinking;
}
return body;
}

View File

@@ -0,0 +1,327 @@
/**
* Rate Limit Manager — Adaptive rate limiting using Bottleneck
*
* Creates per-provider+connection limiters that auto-learn rate limits
* from API response headers (x-ratelimit-*, retry-after, anthropic-ratelimit-*).
*
* Default: DISABLED. Must be enabled per provider connection via dashboard toggle.
*/
import Bottleneck from "bottleneck";
// Store limiters keyed by "provider:connectionId" (and optionally ":model")
const limiters = new Map();
// Store connections that have rate limit protection enabled
const enabledConnections = new Set();
// Track initialization
let initialized = false;
// Default conservative settings (before we learn from headers)
const DEFAULT_SETTINGS = {
maxConcurrent: 10,
minTime: 0, // No throttle by default — let headers teach us
reservoir: null, // No initial reservoir — unlimited until we learn
reservoirRefreshAmount: null,
reservoirRefreshInterval: null,
};
/**
* Initialize rate limit protection from persisted connection settings.
* Called once on app startup.
*/
export async function initializeRateLimits() {
if (initialized) return;
initialized = true;
try {
const { getProviderConnections } = await import("@/lib/localDb.js");
const connections = await getProviderConnections();
let count = 0;
for (const conn of connections) {
if (conn.rateLimitProtection) {
enabledConnections.add(conn.id);
count++;
}
}
if (count > 0) {
console.log(`🛡️ [RATE-LIMIT] Loaded ${count} connection(s) with rate limit protection`);
}
} catch (err) {
console.error("[RATE-LIMIT] Failed to load settings:", err.message);
}
}
/**
* Enable rate limit protection for a connection
*/
export function enableRateLimitProtection(connectionId) {
enabledConnections.add(connectionId);
}
/**
* Disable rate limit protection for a connection
*/
export function disableRateLimitProtection(connectionId) {
enabledConnections.delete(connectionId);
// Clean up limiters for this connection
for (const [key] of limiters) {
if (key.includes(connectionId)) {
const limiter = limiters.get(key);
limiter?.disconnect();
limiters.delete(key);
}
}
}
/**
* Check if rate limit protection is enabled for a connection
*/
export function isRateLimitEnabled(connectionId) {
return enabledConnections.has(connectionId);
}
/**
* Get or create a limiter for a given provider+connection combination
*/
function getLimiter(provider, connectionId, model = null) {
const key = model ? `${provider}:${connectionId}:${model}` : `${provider}:${connectionId}`;
if (!limiters.has(key)) {
const limiter = new Bottleneck({
...DEFAULT_SETTINGS,
id: key,
});
// Log when jobs are queued
limiter.on("queued", () => {
const counts = limiter.counts();
if (counts.QUEUED > 0) {
console.log(
`⏳ [RATE-LIMIT] ${key}${counts.QUEUED} request(s) queued, ${counts.RUNNING} running`
);
}
});
limiters.set(key, limiter);
}
return limiters.get(key);
}
/**
* Acquire a rate limit slot before making a request.
* If rate limiting is disabled for this connection, returns immediately.
*
* @param {string} provider - Provider ID
* @param {string} connectionId - Connection ID
* @param {string} model - Model name (optional, for per-model limits)
* @param {Function} fn - The async function to execute (e.g., executor.execute)
* @returns {Promise<any>} Result of fn()
*/
export async function withRateLimit(provider, connectionId, model, fn) {
if (!enabledConnections.has(connectionId)) {
return fn();
}
const limiter = getLimiter(provider, connectionId, null);
return limiter.schedule(fn);
}
// ─── Header Parsing ──────────────────────────────────────────────────────────
/**
* Standard headers used by most providers (OpenAI, Fireworks, etc.)
*/
const STANDARD_HEADERS = {
limit: "x-ratelimit-limit-requests",
remaining: "x-ratelimit-remaining-requests",
reset: "x-ratelimit-reset-requests",
limitTokens: "x-ratelimit-limit-tokens",
remainingTokens: "x-ratelimit-remaining-tokens",
resetTokens: "x-ratelimit-reset-tokens",
retryAfter: "retry-after",
overLimit: "x-ratelimit-over-limit",
};
/**
* Anthropic uses custom headers
*/
const ANTHROPIC_HEADERS = {
limit: "anthropic-ratelimit-requests-limit",
remaining: "anthropic-ratelimit-requests-remaining",
reset: "anthropic-ratelimit-requests-reset",
limitTokens: "anthropic-ratelimit-input-tokens-limit",
remainingTokens: "anthropic-ratelimit-input-tokens-remaining",
resetTokens: "anthropic-ratelimit-input-tokens-reset",
retryAfter: "retry-after",
};
/**
* Parse a reset time string into milliseconds.
* Formats: "1s", "1m", "1h", "1ms", "60", ISO date, Unix timestamp
*/
function parseResetTime(value) {
if (!value) return null;
// Duration strings: "1s", "500ms", "1m30s"
const durationMatch = value.match(/^(?:(\d+)h)?(?:(\d+)m(?!s))?(?:(\d+)s)?(?:(\d+)ms)?$/);
if (durationMatch) {
const [, h, m, s, ms] = durationMatch;
return (
(parseInt(h || 0) * 3600 + parseInt(m || 0) * 60 + parseInt(s || 0)) * 1000 +
parseInt(ms || 0)
);
}
// Pure number: assume seconds
const num = parseFloat(value);
if (!isNaN(num) && num > 0) {
// If it looks like a Unix timestamp (> year 2025)
if (num > 1700000000) {
return Math.max(0, num * 1000 - Date.now());
}
return num * 1000;
}
// ISO date string
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return Math.max(0, date.getTime() - Date.now());
}
} catch {}
return null;
}
/**
* Update rate limiter based on API response headers.
* Called after every successful or failed response from a provider.
*
* @param {string} provider - Provider ID
* @param {string} connectionId - Connection ID
* @param {Headers} headers - Response headers
* @param {number} status - HTTP status code
* @param {string} model - Model name
*/
export function updateFromHeaders(provider, connectionId, headers, status, model = null) {
if (!enabledConnections.has(connectionId)) return;
if (!headers) return;
const limiter = getLimiter(provider, connectionId, null);
const headerMap =
provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS;
// Get header values (handle both Headers object and plain object)
const getHeader = (name) => {
if (typeof headers.get === "function") return headers.get(name);
return headers[name] || null;
};
const limit = parseInt(getHeader(headerMap.limit));
const remaining = parseInt(getHeader(headerMap.remaining));
const resetStr = getHeader(headerMap.reset);
const retryAfterStr = getHeader(headerMap.retryAfter);
const overLimit = getHeader(STANDARD_HEADERS.overLimit);
// Handle 429 — rate limited
if (status === 429) {
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
console.log(
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s`
);
limiter.updateSettings({
reservoir: 0,
reservoirRefreshAmount: limit || 60,
reservoirRefreshInterval: retryAfterMs,
});
return;
}
// Handle "over limit" soft warning (Fireworks)
if (overLimit === "yes") {
console.log(
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — near capacity, slowing down`
);
limiter.updateSettings({
minTime: 200, // Add 200ms between requests
});
return;
}
// Normal response — update limiter from headers
if (!isNaN(limit) && limit > 0) {
const resetMs = parseResetTime(resetStr) || 60000;
// Calculate optimal minTime from RPM limit
const minTime = Math.max(0, Math.floor(60000 / limit) - 10); // Small buffer
const updates = { minTime };
// If remaining is low (< 10% of limit), set reservoir to throttle immediately
if (!isNaN(remaining)) {
if (remaining < limit * 0.1) {
updates.reservoir = remaining;
updates.reservoirRefreshAmount = limit;
updates.reservoirRefreshInterval = resetMs;
console.log(
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)}${remaining}/${limit} remaining, throttling`
);
} else if (remaining > limit * 0.5) {
// Plenty of headroom — relax the limiter
updates.minTime = 0;
updates.reservoir = null;
updates.reservoirRefreshAmount = null;
updates.reservoirRefreshInterval = null;
}
}
limiter.updateSettings(updates);
}
}
/**
* Get current rate limit status for a provider+connection (for dashboard display)
*/
export function getRateLimitStatus(provider, connectionId) {
const key = `${provider}:${connectionId}`;
const limiter = limiters.get(key);
if (!limiter) {
return {
enabled: enabledConnections.has(connectionId),
active: false,
queued: 0,
running: 0,
};
}
const counts = limiter.counts();
return {
enabled: enabledConnections.has(connectionId),
active: true,
queued: counts.QUEUED || 0,
running: counts.RUNNING || 0,
executing: counts.EXECUTING || 0,
done: counts.DONE || 0,
};
}
/**
* Get all active limiters status (for dashboard overview)
*/
export function getAllRateLimitStatus() {
const result = {};
for (const [key, limiter] of limiters) {
const counts = limiter.counts();
result[key] = {
queued: counts.QUEUED || 0,
running: counts.RUNNING || 0,
executing: counts.EXECUTING || 0,
};
}
return result;
}

View File

@@ -0,0 +1,179 @@
/**
* Rate-Limit Semaphore
*
* Per-model concurrency limiter with FIFO queue for round-robin combo strategy.
* When a model is at max concurrency, requests wait in a queue instead of failing.
* When a model hits rate-limits, it's temporarily paused and queued requests wait.
*
* All state is in-memory — resets on server restart (by design, since rate-limit
* windows are typically short-lived).
*/
/**
* @typedef {Object} ModelGate
* @property {number} running - Currently running requests
* @property {number} max - Max concurrent requests
* @property {Array<{resolve: Function, reject: Function, timer: NodeJS.Timeout}>} queue - FIFO wait queue
* @property {number|null} rateLimitedUntil - Timestamp when rate-limit expires (null = not limited)
*/
/** @type {Map<string, ModelGate>} */
const gates = new Map();
/**
* Get or create gate for a model
* @param {string} modelStr
* @param {number} maxConcurrency
* @returns {ModelGate}
*/
function getGate(modelStr, maxConcurrency = 3) {
if (!gates.has(modelStr)) {
gates.set(modelStr, {
running: 0,
max: maxConcurrency,
queue: [],
rateLimitedUntil: null,
});
}
const gate = gates.get(modelStr);
// Update max if config changed
gate.max = maxConcurrency;
return gate;
}
/**
* Check if a model is currently rate-limited
* @param {ModelGate} gate
* @returns {boolean}
*/
function isRateLimited(gate) {
if (!gate.rateLimitedUntil) return false;
if (Date.now() >= gate.rateLimitedUntil) {
gate.rateLimitedUntil = null;
return false;
}
return true;
}
/**
* Try to drain queued requests when slots become available
* @param {string} modelStr
*/
function drainQueue(modelStr) {
const gate = gates.get(modelStr);
if (!gate) return;
while (gate.queue.length > 0 && gate.running < gate.max && !isRateLimited(gate)) {
const next = gate.queue.shift();
clearTimeout(next.timer);
gate.running++;
next.resolve(createReleaseFn(modelStr));
}
}
/**
* Create a release function for a slot
* @param {string} modelStr
* @returns {Function}
*/
function createReleaseFn(modelStr) {
let released = false;
return () => {
if (released) return;
released = true;
const gate = gates.get(modelStr);
if (gate && gate.running > 0) {
gate.running--;
drainQueue(modelStr);
}
};
}
/**
* Acquire a concurrency slot for a model.
* If slots are available and model is not rate-limited, resolves immediately.
* Otherwise waits in a FIFO queue until a slot opens or timeout expires.
*
* @param {string} modelStr - The model identifier
* @param {Object} [options]
* @param {number} [options.maxConcurrency=3] - Max concurrent requests for this model
* @param {number} [options.timeoutMs=30000] - Max wait time in queue
* @returns {Promise<Function>} Release function — MUST be called when done
* @throws {Error} If queue timeout expires ("SEMAPHORE_TIMEOUT")
*/
export function acquire(modelStr, { maxConcurrency = 3, timeoutMs = 30000 } = {}) {
const gate = getGate(modelStr, maxConcurrency);
// Fast path: slot available and not rate-limited
if (gate.running < gate.max && !isRateLimited(gate)) {
gate.running++;
return Promise.resolve(createReleaseFn(modelStr));
}
// Slow path: enqueue and wait
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
// Remove from queue on timeout
const idx = gate.queue.findIndex((item) => item.timer === timer);
if (idx !== -1) gate.queue.splice(idx, 1);
const err = new Error(`Semaphore timeout after ${timeoutMs}ms for ${modelStr}`);
err.code = "SEMAPHORE_TIMEOUT";
reject(err);
}, timeoutMs);
gate.queue.push({ resolve, reject, timer });
});
}
/**
* Mark a model as rate-limited for a given duration.
* Existing running requests continue, but new acquisitions are blocked
* until the cooldown expires. After expiry, the queue drains automatically.
*
* @param {string} modelStr - The model identifier
* @param {number} cooldownMs - How long to block (milliseconds)
*/
export function markRateLimited(modelStr, cooldownMs) {
const gate = getGate(modelStr);
gate.rateLimitedUntil = Date.now() + cooldownMs;
// Schedule drain after cooldown expires
setTimeout(() => {
if (gate.rateLimitedUntil && Date.now() >= gate.rateLimitedUntil) {
gate.rateLimitedUntil = null;
drainQueue(modelStr);
}
}, cooldownMs + 50); // +50ms buffer
}
/**
* Get stats for all tracked models (for monitoring/UI)
* @returns {Object} Map of modelStr → { running, queued, max, rateLimitedUntil }
*/
export function getStats() {
const stats = {};
for (const [model, gate] of gates) {
stats[model] = {
running: gate.running,
queued: gate.queue.length,
max: gate.max,
rateLimitedUntil: gate.rateLimitedUntil
? new Date(gate.rateLimitedUntil).toISOString()
: null,
};
}
return stats;
}
/**
* Reset all gates (for testing)
*/
export function resetAll() {
for (const [, gate] of gates) {
for (const item of gate.queue) {
clearTimeout(item.timer);
item.reject(new Error("Semaphore reset"));
}
}
gates.clear();
}

View File

@@ -0,0 +1,809 @@
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
import { createHash } from "node:crypto";
// Token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
// In-flight refresh promise cache to prevent race conditions
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
const refreshPromiseCache = new Map();
function getRefreshCacheKey(provider, refreshToken) {
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
return `${provider}:${tokenHash}`;
}
/**
* Refresh OAuth access token using refresh token
*/
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
const config = PROVIDERS[provider];
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
if (!config || !refreshEndpoint) {
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
return null;
}
if (!refreshToken) {
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
return null;
}
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
});
if (config.clientId) params.set("client_id", config.clientId);
if (config.clientSecret) params.set("client_secret", config.clientSecret);
const response = await fetch(refreshEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params,
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
error: error.message,
});
return null;
}
}
/**
* Specialized refresh for Cline OAuth tokens.
* Cline refresh endpoint expects JSON body and returns camelCase fields.
*/
export async function refreshClineToken(refreshToken, log) {
const endpoint = PROVIDERS.cline?.refreshUrl;
if (!endpoint) {
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
return null;
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
refreshToken,
grantType: "refresh_token",
clientType: "extension",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
status: response.status,
error: errorText,
});
return null;
}
const payload = await response.json();
const data = payload?.data || payload;
const expiresAtIso = data?.expiresAt;
const expiresIn = expiresAtIso
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
: undefined;
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
hasNewAccessToken: !!data?.accessToken,
hasNewRefreshToken: !!data?.refreshToken,
expiresIn,
});
return {
accessToken: data?.accessToken,
refreshToken: data?.refreshToken || refreshToken,
expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Kimi Coding OAuth tokens.
*/
export async function refreshKimiCodingToken(refreshToken, log) {
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
if (!endpoint) {
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
return null;
}
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
});
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params,
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Claude OAuth tokens
*/
export async function refreshClaudeOAuthToken(refreshToken, log) {
try {
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.claude.clientId,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for Google providers (Gemini, Antigravity)
*/
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
const response = await fetch(OAUTH_ENDPOINTS.google.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: clientId,
client_secret: clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Specialized refresh for Qwen OAuth tokens
*/
export async function refreshQwenToken(refreshToken, log) {
const endpoint = OAUTH_ENDPOINTS.qwen.token;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.qwen.clientId,
}),
});
if (response.status === 200) {
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} else {
const errorText = await response.text().catch(() => "");
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
status: response.status,
error: errorText,
});
}
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
error: error.message,
});
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
return null;
}
/**
* Specialized refresh for Codex (OpenAI) OAuth tokens
*/
export async function refreshCodexToken(refreshToken, log) {
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();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
*/
export async function refreshKiroToken(refreshToken, providerSpecificData, log) {
try {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint =
isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientId,
clientSecret: clientSecret,
refreshToken: refreshToken,
grantType: "refresh_token",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
}
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
const response = await fetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
refreshToken: refreshToken,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
return null;
}
}
/**
* Specialized refresh for iFlow OAuth tokens
*/
export async function refreshIflowToken(refreshToken, log) {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.iflow.clientId,
client_secret: PROVIDERS.iflow.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Specialized refresh for GitHub Copilot OAuth tokens
*/
export async function refreshGitHubToken(refreshToken, log) {
const response = await fetch(OAUTH_ENDPOINTS.github.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.github.clientId,
client_secret: PROVIDERS.github.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}
/**
* Refresh GitHub Copilot token using GitHub access token
*/
export async function refreshCopilotToken(githubAccessToken, log) {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
Authorization: `token ${githubAccessToken}`,
"User-Agent": "GithubCopilot/1.0",
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot/1.300.0",
Accept: "application/json",
},
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
hasToken: !!data.token,
expiresAt: data.expires_at,
});
return {
token: data.token,
expiresAt: data.expires_at,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
error: error.message,
});
return null;
}
}
/**
* Get access token for a specific provider (internal, does the actual work)
*/
async function _getAccessTokenInternal(provider, credentials, log) {
switch (provider) {
case "gemini":
case "gemini-cli":
case "antigravity":
return await refreshGoogleToken(
credentials.refreshToken,
PROVIDERS[provider].clientId,
PROVIDERS[provider].clientSecret,
log
);
case "claude":
return await refreshClaudeOAuthToken(credentials.refreshToken, log);
case "codex":
return await refreshCodexToken(credentials.refreshToken, log);
case "qwen":
return await refreshQwenToken(credentials.refreshToken, log);
case "iflow":
return await refreshIflowToken(credentials.refreshToken, log);
case "github":
return await refreshGitHubToken(credentials.refreshToken, log);
case "kiro":
return await refreshKiroToken(
credentials.refreshToken,
credentials.providerSpecificData,
log
);
case "cline":
return await refreshClineToken(credentials.refreshToken, log);
case "kimi-coding":
return await refreshKimiCodingToken(credentials.refreshToken, log);
default:
// Fallback to generic OAuth refresh for unknown providers
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
}
}
/**
* Whether a provider has a supported refresh path in this service.
*/
export function supportsTokenRefresh(provider) {
const explicitlySupported = new Set([
"gemini",
"gemini-cli",
"antigravity",
"claude",
"codex",
"qwen",
"iflow",
"github",
"kiro",
"cline",
"kimi-coding",
]);
if (explicitlySupported.has(provider)) return true;
const config = PROVIDERS[provider];
return !!(config?.refreshUrl || config?.tokenUrl);
}
/**
* Get access token for a specific provider (with deduplication).
* If a refresh is already in-flight for the same provider+token,
* subsequent calls share the existing promise instead of making
* parallel OAuth requests.
*/
export async function getAccessToken(provider, credentials, log) {
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
return null;
}
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
// If a refresh is already in-flight, reuse it
if (refreshPromiseCache.has(cacheKey)) {
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
return refreshPromiseCache.get(cacheKey);
}
// Start a new refresh and cache the promise
const refreshPromise = _getAccessTokenInternal(provider, credentials, log).finally(() => {
refreshPromiseCache.delete(cacheKey);
});
refreshPromiseCache.set(cacheKey, refreshPromise);
return refreshPromise;
}
/**
* Refresh token by provider type (alias for getAccessToken)
* @deprecated Since v0.2.70 — use getAccessToken() directly.
* Still exported because open-sse/index.js and src/sse wrapper use it.
* Will be removed in a future major version.
*/
export const refreshTokenByProvider = getAccessToken;
/**
* Format credentials for provider
*/
export function formatProviderCredentials(provider, credentials, log) {
const config = PROVIDERS[provider];
if (!config) {
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
return null;
}
switch (provider) {
case "gemini":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
projectId: credentials.projectId,
};
case "claude":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
};
case "codex":
case "qwen":
case "iflow":
case "openai":
case "openrouter":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
};
case "antigravity":
case "gemini-cli":
return {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
};
default:
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
};
}
}
/**
* Get all access tokens for a user
*/
export async function getAllAccessTokens(userInfo, log) {
const results = {};
if (userInfo.connections && Array.isArray(userInfo.connections)) {
for (const connection of userInfo.connections) {
if (connection.isActive && connection.provider) {
const token = await getAccessToken(
connection.provider,
{
refreshToken: connection.refreshToken,
},
log
);
if (token) {
results[connection.provider] = token;
}
}
}
}
return results;
}
/**
* Refresh token with retry and exponential backoff
* Retries on failure with increasing delay: 1s, 2s, 3s...
* @param {function} refreshFn - Async function that returns token or null
* @param {number} maxRetries - Max retry attempts (default 3)
* @param {object} log - Logger instance (optional)
* @returns {Promise<object|null>} Token result or null if all retries fail
*/
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
const delay = attempt * 1000;
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
}
try {
const result = await refreshFn();
if (result) return result;
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
}
}
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`);
return null;
}

647
open-sse/services/usage.js Normal file
View File

@@ -0,0 +1,647 @@
/**
* Usage Fetcher - Get usage data from provider APIs
*/
import { PROVIDERS } from "../config/constants.js";
// GitHub API config
const GITHUB_CONFIG = {
apiVersion: "2022-11-28",
userAgent: "GitHubCopilotChat/0.26.7",
};
// Antigravity API config (credentials from PROVIDERS via credential loader)
const ANTIGRAVITY_CONFIG = {
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
tokenUrl: "https://oauth2.googleapis.com/token",
get clientId() {
return PROVIDERS.antigravity.clientId;
},
get clientSecret() {
return PROVIDERS.antigravity.clientSecret;
},
userAgent: "antigravity/1.11.3 Darwin/arm64",
};
// Codex (OpenAI) API config
const CODEX_CONFIG = {
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
};
// Claude API config
const CLAUDE_CONFIG = {
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
settingsUrl: "https://api.anthropic.com/v1/settings",
};
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
* @returns {Object} Usage data with quotas
*/
export async function getUsageForProvider(connection) {
const { provider, accessToken, providerSpecificData } = connection;
switch (provider) {
case "github":
return await getGitHubUsage(accessToken, providerSpecificData);
case "gemini-cli":
return await getGeminiUsage(accessToken);
case "antigravity":
return await getAntigravityUsage(accessToken);
case "claude":
return await getClaudeUsage(accessToken);
case "codex":
return await getCodexUsage(accessToken);
case "kiro":
return await getKiroUsage(accessToken, providerSpecificData);
case "qwen":
return await getQwenUsage(accessToken, providerSpecificData);
case "iflow":
return await getIflowUsage(accessToken);
default:
return { message: `Usage API not implemented for ${provider}` };
}
}
/**
* Parse reset date/time to ISO string
* Handles multiple formats: Unix timestamp (ms), ISO date string, etc.
*/
function parseResetTime(resetValue) {
if (!resetValue) return null;
try {
// If it's already a Date object
if (resetValue instanceof Date) {
return resetValue.toISOString();
}
// If it's a number (Unix timestamp in milliseconds)
if (typeof resetValue === "number") {
return new Date(resetValue).toISOString();
}
// If it's a string (ISO date or any parseable date string)
if (typeof resetValue === "string") {
return new Date(resetValue).toISOString();
}
return null;
} catch (error) {
console.warn(`Failed to parse reset time: ${resetValue}`, error);
return null;
}
}
/**
* GitHub Copilot Usage
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
*/
async function getGitHubUsage(accessToken, providerSpecificData) {
try {
if (!accessToken) {
throw new Error("No GitHub access token available. Please re-authorize the connection.");
}
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
const response = await fetch("https://api.github.com/copilot_internal/user", {
headers: {
Authorization: `token ${accessToken}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`GitHub API error: ${error}`);
}
const data = await response.json();
// Handle different response formats (paid vs free)
if (data.quota_snapshots) {
// Paid plan format
const snapshots = data.quota_snapshots;
const resetAt = parseResetTime(data.quota_reset_date);
return {
plan: data.copilot_plan,
resetDate: data.quota_reset_date,
quotas: {
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
premium_interactions: {
...formatGitHubQuotaSnapshot(snapshots.premium_interactions),
resetAt,
},
},
};
} else if (data.monthly_quotas || data.limited_user_quotas) {
// Free/limited plan format
const monthlyQuotas = data.monthly_quotas || {};
const usedQuotas = data.limited_user_quotas || {};
const resetAt = parseResetTime(data.limited_user_reset_date);
return {
plan: data.copilot_plan || data.access_type_sku,
resetDate: data.limited_user_reset_date,
quotas: {
chat: {
used: usedQuotas.chat || 0,
total: monthlyQuotas.chat || 0,
unlimited: false,
resetAt,
},
completions: {
used: usedQuotas.completions || 0,
total: monthlyQuotas.completions || 0,
unlimited: false,
resetAt,
},
},
};
}
return { message: "GitHub Copilot connected. Unable to parse quota data." };
} catch (error) {
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
}
}
function formatGitHubQuotaSnapshot(quota) {
if (!quota) return { used: 0, total: 0, unlimited: true };
return {
used: quota.entitlement - quota.remaining,
total: quota.entitlement,
remaining: quota.remaining,
unlimited: quota.unlimited || false,
};
}
/**
* Gemini CLI Usage (Google Cloud)
*/
async function getGeminiUsage(accessToken) {
try {
// Gemini CLI uses Google Cloud quotas
// Try to get quota info from Cloud Resource Manager
const response = await fetch(
"https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
}
);
if (!response.ok) {
// Quota API may not be accessible, return generic message
return {
message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details.",
};
}
return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." };
} catch (error) {
return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." };
}
}
// ── Antigravity subscription info cache ──────────────────────────────────────
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
// Key: truncated accessToken → { data, fetchedAt }
const _antigravitySubCache = new Map();
const ANTIGRAVITY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Map raw loadCodeAssist tier data to short display labels.
* Extracts tier from allowedTiers[].isDefault (same logic as providers.js postExchange).
* Falls back to currentTier.id → currentTier.name → "Free".
*/
function getAntigravityPlanLabel(subscriptionInfo) {
if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free";
// 1. Extract tier from allowedTiers (primary source — same as providers.js)
let tierId = "";
if (Array.isArray(subscriptionInfo.allowedTiers)) {
for (const tier of subscriptionInfo.allowedTiers) {
if (tier.isDefault && tier.id) {
tierId = tier.id.trim().toUpperCase();
break;
}
}
}
// 2. Fall back to currentTier.id
if (!tierId) {
tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase();
}
// 3. Map tier ID to display label
if (tierId) {
if (tierId.includes("ULTRA")) return "Ultra";
if (tierId.includes("PRO")) return "Pro";
if (tierId.includes("ENTERPRISE")) return "Enterprise";
if (tierId.includes("BUSINESS") || tierId.includes("STANDARD")) return "Business";
if (tierId.includes("FREE") || tierId.includes("INDIVIDUAL") || tierId.includes("LEGACY"))
return "Free";
}
// 4. Try tier name fields as last resort
const tierName =
subscriptionInfo.currentTier?.name ||
subscriptionInfo.currentTier?.displayName ||
subscriptionInfo.subscriptionType ||
subscriptionInfo.tier ||
"";
const upper = tierName.toUpperCase();
if (upper.includes("ULTRA")) return "Ultra";
if (upper.includes("PRO")) return "Pro";
if (upper.includes("ENTERPRISE")) return "Enterprise";
if (upper.includes("STANDARD") || upper.includes("BUSINESS")) return "Business";
if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free";
// 5. If upgradeSubscriptionType exists, account is on free tier
if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free";
// 6. If we have a tier name that didn't match any pattern, return it title-cased
if (tierName) {
return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase();
}
return "Free";
}
/**
* Antigravity Usage - Fetch quota from Google Cloud Code API
* Now calls loadCodeAssist ONCE (cached) and reuses for projectId + plan.
*/
async function getAntigravityUsage(accessToken, providerSpecificData) {
try {
// Single cached call for subscription info (provides both projectId and plan)
const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken);
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
// Fetch quota data
const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
},
body: JSON.stringify(projectId ? { project: projectId } : {}),
});
if (response.status === 403) {
return { message: "Antigravity access forbidden. Check subscription." };
}
if (!response.ok) {
throw new Error(`Antigravity API error: ${response.status}`);
}
const data = await response.json();
const quotas = {};
// Parse model quotas (inspired by vscode-antigravity-cockpit)
if (data.models) {
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
const importantModels = [
"claude-opus-4-6-thinking",
"claude-opus-4-5-thinking",
"claude-opus-4-5",
"claude-sonnet-4-5-thinking",
"claude-sonnet-4-5",
"gemini-3-pro-high",
"gemini-3-pro-low",
"gemini-3-flash",
"gemini-2.5-flash",
];
for (const [modelKey, info] of Object.entries(data.models)) {
// Skip models without quota info
if (!info.quotaInfo) {
continue;
}
// Skip internal models and non-important models
if (info.isInternal || !importantModels.includes(modelKey)) {
continue;
}
const remainingFraction = info.quotaInfo.remainingFraction || 0;
const remainingPercentage = remainingFraction * 100;
// Convert percentage to used/total for UI compatibility
// QUOTA_NORMALIZED_BASE is an arbitrary base for converting fractions
// to integer used/total pairs that the dashboard UI can display as bars.
const QUOTA_NORMALIZED_BASE = 1000;
const total = QUOTA_NORMALIZED_BASE;
const remaining = Math.round(total * remainingFraction);
const used = total - remaining;
// Use modelKey as key (matches PROVIDER_MODELS id)
quotas[modelKey] = {
used,
total,
resetAt: parseResetTime(info.quotaInfo.resetTime),
remainingPercentage,
unlimited: false,
displayName: info.displayName || modelKey,
};
}
}
return {
plan: getAntigravityPlanLabel(subscriptionInfo),
quotas,
subscriptionInfo,
};
} catch (error) {
return { message: `Antigravity error: ${error.message}` };
}
}
/**
* Get Antigravity subscription info (cached, 5 min TTL)
* Prevents duplicate loadCodeAssist calls within the same quota cycle.
*/
async function getAntigravitySubscriptionInfoCached(accessToken) {
const cacheKey = accessToken.substring(0, 16);
const cached = _antigravitySubCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ANTIGRAVITY_CACHE_TTL_MS) {
return cached.data;
}
const data = await getAntigravitySubscriptionInfo(accessToken);
_antigravitySubCache.set(cacheKey, { data, fetchedAt: Date.now() });
return data;
}
/**
* Get Antigravity subscription info using correct Antigravity headers.
* Must match the headers used in providers.js postExchange (not CLI headers).
*/
async function getAntigravitySubscriptionInfo(accessToken) {
try {
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
}
/**
* Claude Usage - Try to fetch from Anthropic API
*/
async function getClaudeUsage(accessToken) {
try {
// Try to get organization/account settings first
const settingsResponse = await fetch("https://api.anthropic.com/v1/settings", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
},
});
if (settingsResponse.ok) {
const settings = await settingsResponse.json();
// Try usage endpoint if we have org info
if (settings.organization_id) {
const usageResponse = await fetch(
`https://api.anthropic.com/v1/organizations/${settings.organization_id}/usage`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
},
}
);
if (usageResponse.ok) {
const usage = await usageResponse.json();
return {
plan: settings.plan || "Unknown",
organization: settings.organization_name,
quotas: usage,
};
}
}
return {
plan: settings.plan || "Unknown",
organization: settings.organization_name,
message: "Claude connected. Usage details require admin access.",
};
}
// If settings API fails, OAuth token may not have required scope
return { message: "Claude connected. Usage API requires admin permissions." };
} catch (error) {
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
}
}
/**
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
*/
async function getCodexUsage(accessToken) {
try {
const response = await fetch(CODEX_CONFIG.usageUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Codex API error: ${response.status}`);
}
const data = await response.json();
// Parse rate limit info
const rateLimit = data.rate_limit || {};
const primaryWindow = rateLimit.primary_window || {};
const secondaryWindow = rateLimit.secondary_window || {};
// Parse reset dates (reset_at is Unix timestamp in seconds, multiply by 1000 for ms)
const sessionResetAt = parseResetTime(
primaryWindow.reset_at ? primaryWindow.reset_at * 1000 : null
);
const weeklyResetAt = parseResetTime(
secondaryWindow.reset_at ? secondaryWindow.reset_at * 1000 : null
);
return {
plan: data.plan_type || "unknown",
limitReached: rateLimit.limit_reached || false,
quotas: {
session: {
used: primaryWindow.used_percent || 0,
total: 100,
remaining: 100 - (primaryWindow.used_percent || 0),
resetAt: sessionResetAt,
unlimited: false,
},
weekly: {
used: secondaryWindow.used_percent || 0,
total: 100,
remaining: 100 - (secondaryWindow.used_percent || 0),
resetAt: weeklyResetAt,
unlimited: false,
},
},
};
} catch (error) {
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
}
}
/**
* Kiro (AWS CodeWhisperer) Usage
*/
async function getKiroUsage(accessToken, providerSpecificData) {
try {
const profileArn = providerSpecificData?.profileArn;
if (!profileArn) {
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
}
// Kiro uses AWS CodeWhisperer GetUsageLimits API
const payload = {
origin: "AI_EDITOR",
profileArn: profileArn,
resourceType: "AGENTIC_REQUEST",
};
const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
}
const data = await response.json();
// Parse usage data from usageBreakdownList
const usageList = data.usageBreakdownList || [];
const quotaInfo = {};
// Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.)
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
usageList.forEach((breakdown) => {
const resourceType = breakdown.resourceType?.toLowerCase() || "unknown";
const used = breakdown.currentUsageWithPrecision || 0;
const total = breakdown.usageLimitWithPrecision || 0;
quotaInfo[resourceType] = {
used,
total,
remaining: total - used,
resetAt,
unlimited: false,
};
// Add free trial if available
if (breakdown.freeTrialInfo) {
const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0;
const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0;
quotaInfo[`${resourceType}_freetrial`] = {
used: freeUsed,
total: freeTotal,
remaining: freeTotal - freeUsed,
resetAt,
unlimited: false,
};
}
});
return {
plan: data.subscriptionInfo?.subscriptionTitle || "Kiro",
quotas: quotaInfo,
};
} catch (error) {
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
}
}
/**
* Qwen Usage
*/
async function getQwenUsage(accessToken, providerSpecificData) {
try {
const resourceUrl = providerSpecificData?.resourceUrl;
if (!resourceUrl) {
return { message: "Qwen connected. No resource URL available." };
}
// Qwen may have usage endpoint at resource URL
return { message: "Qwen connected. Usage tracked per request." };
} catch (error) {
return { message: "Unable to fetch Qwen usage." };
}
}
/**
* iFlow Usage
*/
async function getIflowUsage(accessToken) {
try {
// iFlow may have usage endpoint
return { message: "iFlow connected. Usage tracked per request." };
} catch (error) {
return { message: "Unable to fetch iFlow usage." };
}
}

View File

@@ -0,0 +1,459 @@
/**
* Responses API Transformer
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
* Can be used in both Next.js and Cloudflare Workers
*/
// Dynamic import for Node.js-only modules (fs/path unavailable in Workers)
let _fs = null;
let _path = null;
async function getFs() {
if (_fs === null) {
try {
_fs = (await import("fs")).default;
} catch {
_fs = false;
}
}
return _fs || null;
}
async function getPath() {
if (_path === null) {
try {
_path = (await import("path")).default;
} catch {
_path = false;
}
}
return _path || null;
}
// Create log directory for responses (Node.js only)
export function createResponsesLogger(model, logsDir = null) {
// Skip logging in worker environment (no fs)
if (typeof fs.mkdirSync !== "function") {
return null;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
const uniqueId = Math.random().toString(36).slice(2, 8);
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
try {
fs.mkdirSync(logDir, { recursive: true });
} catch {
return null;
}
let inputEvents = [];
let outputEvents = [];
return {
logInput: (event) => {
inputEvents.push(event);
},
logOutput: (event) => {
outputEvents.push(event);
},
flush: () => {
try {
fs.writeFileSync(path.join(logDir, "1_input_stream.txt"), inputEvents.join("\n"));
fs.writeFileSync(path.join(logDir, "2_output_stream.txt"), outputEvents.join("\n"));
} catch (e) {
console.log("[RESPONSES] Failed to write logs:", e.message);
}
},
};
}
/**
* Create TransformStream that converts Chat Completions SSE to Responses API SSE
* @param {Object} logger - Optional logger instance
* @returns {TransformStream}
*/
export function createResponsesApiTransformStream(logger = null) {
const state = {
seq: 0,
responseId: `resp_${Date.now()}`,
created: Math.floor(Date.now() / 1000),
started: false,
msgTextBuf: {},
msgItemAdded: {},
msgContentAdded: {},
msgItemDone: {},
reasoningId: "",
reasoningIndex: -1,
reasoningBuf: "",
reasoningPartAdded: false,
reasoningDone: false,
inThinking: false,
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcArgsDone: {},
funcItemDone: {},
buffer: "",
completedSent: false,
};
const encoder = new TextEncoder();
const nextSeq = () => ++state.seq;
const emit = (controller, eventType, data) => {
data.sequence_number = nextSeq();
const output = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
logger?.logOutput(output.trim());
controller.enqueue(encoder.encode(output));
};
// Helper to start reasoning
const startReasoning = (controller, idx) => {
if (!state.reasoningId) {
state.reasoningId = `rs_${state.responseId}_${idx}`;
state.reasoningIndex = idx;
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
item: {
id: state.reasoningId,
type: "reasoning",
summary: [],
},
});
emit(controller, "response.reasoning_summary_part.added", {
type: "response.reasoning_summary_part.added",
item_id: state.reasoningId,
output_index: idx,
summary_index: 0,
part: { type: "summary_text", text: "" },
});
state.reasoningPartAdded = true;
}
};
const emitReasoningDelta = (controller, text) => {
if (!text) return;
state.reasoningBuf += text;
emit(controller, "response.reasoning_summary_text.delta", {
type: "response.reasoning_summary_text.delta",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
delta: text,
});
};
const closeReasoning = (controller) => {
if (state.reasoningId && !state.reasoningDone) {
state.reasoningDone = true;
emit(controller, "response.reasoning_summary_text.done", {
type: "response.reasoning_summary_text.done",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
text: state.reasoningBuf,
});
emit(controller, "response.reasoning_summary_part.done", {
type: "response.reasoning_summary_part.done",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
part: { type: "summary_text", text: state.reasoningBuf },
});
emit(controller, "response.output_item.done", {
type: "response.output_item.done",
output_index: state.reasoningIndex,
item: {
id: state.reasoningId,
type: "reasoning",
summary: [{ type: "summary_text", text: state.reasoningBuf }],
},
});
}
};
const closeMessage = (controller, idx) => {
if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) {
state.msgItemDone[idx] = true;
const fullText = state.msgTextBuf[idx] || "";
const msgId = `msg_${state.responseId}_${idx}`;
emit(controller, "response.output_text.done", {
type: "response.output_text.done",
item_id: msgId,
output_index: parseInt(idx),
content_index: 0,
text: fullText,
logprobs: [],
});
emit(controller, "response.content_part.done", {
type: "response.content_part.done",
item_id: msgId,
output_index: parseInt(idx),
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: fullText },
});
emit(controller, "response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: msgId,
type: "message",
content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
role: "assistant",
},
});
}
};
const closeToolCall = (controller, idx) => {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}";
emit(controller, "response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args,
});
emit(controller, "response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: `fc_${callId}`,
type: "function_call",
arguments: args,
call_id: callId,
name: state.funcNames[idx] || "",
},
});
state.funcItemDone[idx] = true;
state.funcArgsDone[idx] = true;
}
};
const sendCompleted = (controller) => {
if (!state.completedSent) {
state.completedSent = true;
emit(controller, "response.completed", {
type: "response.completed",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "completed",
background: false,
error: null,
},
});
}
};
return new TransformStream({
transform(chunk, controller) {
const text = new TextDecoder().decode(chunk);
logger?.logInput(text.trim());
state.buffer += text;
const messages = state.buffer.split("\n\n");
state.buffer = messages.pop() || "";
for (const msg of messages) {
if (!msg.trim()) continue;
const dataMatch = msg.match(/^data:\s*(.+)$/m);
if (!dataMatch) continue;
const dataStr = dataMatch[1].trim();
if (dataStr === "[DONE]") continue;
let parsed;
try {
parsed = JSON.parse(dataStr);
} catch {
continue;
}
if (!parsed.choices?.length) continue;
const choice = parsed.choices[0];
const idx = choice.index || 0;
const delta = choice.delta || {};
// Emit initial events
if (!state.started) {
state.started = true;
state.responseId = parsed.id ? `resp_${parsed.id}` : state.responseId;
emit(controller, "response.created", {
type: "response.created",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "in_progress",
background: false,
error: null,
output: [],
},
});
emit(controller, "response.in_progress", {
type: "response.in_progress",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "in_progress",
},
});
}
// Handle reasoning_content (OpenAI native format)
if (delta.reasoning_content) {
startReasoning(controller, idx);
emitReasoningDelta(controller, delta.reasoning_content);
}
// Handle text content (may contain <think> tags)
if (delta.content) {
let content = delta.content;
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replace("<think>", "");
startReasoning(controller, idx);
}
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(controller, thinkPart);
closeReasoning(controller);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(controller, content);
continue;
}
// Regular text content
if (content) {
if (!state.msgItemAdded[idx]) {
state.msgItemAdded[idx] = true;
const msgId = `msg_${state.responseId}_${idx}`;
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
item: { id: msgId, type: "message", content: [], role: "assistant" },
});
}
if (!state.msgContentAdded[idx]) {
state.msgContentAdded[idx] = true;
emit(controller, "response.content_part.added", {
type: "response.content_part.added",
item_id: `msg_${state.responseId}_${idx}`,
output_index: idx,
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: "" },
});
}
emit(controller, "response.output_text.delta", {
type: "response.output_text.delta",
item_id: `msg_${state.responseId}_${idx}`,
output_index: idx,
content_index: 0,
delta: content,
logprobs: [],
});
if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = "";
state.msgTextBuf[idx] += content;
}
}
// Handle tool_calls
if (delta.tool_calls) {
closeMessage(controller, idx);
for (const tc of delta.tool_calls) {
const tcIdx = tc.index ?? 0;
const newCallId = tc.id;
const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName;
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: "function_call",
arguments: "",
call_id: newCallId,
name: state.funcNames[tcIdx] || "",
},
});
}
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) {
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
output_index: tcIdx,
delta: tc.function.arguments,
});
}
state.funcArgsBuf[tcIdx] += tc.function.arguments;
}
}
}
// Handle finish_reason
if (choice.finish_reason) {
for (const i in state.msgItemAdded) closeMessage(controller, i);
closeReasoning(controller);
for (const i in state.funcCallIds) closeToolCall(controller, i);
sendCompleted(controller);
}
}
},
flush(controller) {
for (const i in state.msgItemAdded) closeMessage(controller, i);
closeReasoning(controller);
for (const i in state.funcCallIds) closeToolCall(controller, i);
sendCompleted(controller);
logger?.logOutput("data: [DONE]");
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
logger?.flush();
},
});
}

View File

@@ -0,0 +1,13 @@
// Format identifiers
export const FORMATS = {
OPENAI: "openai",
OPENAI_RESPONSES: "openai-responses",
OPENAI_RESPONSE: "openai-response",
CLAUDE: "claude",
GEMINI: "gemini",
GEMINI_CLI: "gemini-cli",
CODEX: "codex",
ANTIGRAVITY: "antigravity",
KIRO: "kiro",
CURSOR: "cursor",
};

View File

@@ -0,0 +1,191 @@
// Claude helper functions for translator
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js";
// Check if message has valid non-empty content
export function hasValidContent(msg) {
if (typeof msg.content === "string" && msg.content.trim()) return true;
if (Array.isArray(msg.content)) {
return msg.content.some(
(block) =>
(block.type === "text" && block.text?.trim()) ||
block.type === "tool_use" ||
block.type === "tool_result"
);
}
return false;
}
// Fix tool_use/tool_result ordering for Claude API
// 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow)
// 2. Merge consecutive same-role messages
export function fixToolUseOrdering(messages) {
if (messages.length <= 1) return messages;
// Pass 1: Fix assistant messages with tool_use - remove text after tool_use
for (const msg of messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const hasToolUse = msg.content.some((b) => b.type === "tool_use");
if (hasToolUse) {
// Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use)
const newContent = [];
let foundToolUse = false;
for (const block of msg.content) {
if (block.type === "tool_use") {
foundToolUse = true;
newContent.push(block);
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
newContent.push(block);
} else if (!foundToolUse) {
// Keep text blocks BEFORE tool_use
newContent.push(block);
}
// Skip text blocks AFTER tool_use
}
msg.content = newContent;
}
}
}
// Pass 2: Merge consecutive same-role messages
const merged = [];
for (const msg of messages) {
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
// Merge content arrays
const lastContent = Array.isArray(last.content)
? last.content
: [{ type: "text", text: last.content }];
const msgContent = Array.isArray(msg.content)
? msg.content
: [{ type: "text", text: msg.content }];
// Put tool_result first, then other content
const toolResults = [
...lastContent.filter((b) => b.type === "tool_result"),
...msgContent.filter((b) => b.type === "tool_result"),
];
const otherContent = [
...lastContent.filter((b) => b.type !== "tool_result"),
...msgContent.filter((b) => b.type !== "tool_result"),
];
last.content = [...toolResults, ...otherContent];
} else {
// Ensure content is array
const content = Array.isArray(msg.content)
? msg.content
: [{ type: "text", text: msg.content }];
merged.push({ role: msg.role, content: [...content] });
}
}
return merged;
}
// Prepare request for Claude format endpoints
// - Cleanup cache_control
// - Filter empty messages
// - Add thinking block for Anthropic endpoint (provider === "claude")
// - Fix tool_use/tool_result ordering
export function prepareClaudeRequest(body, provider = null) {
// 1. System: remove all cache_control, add only to last block with ttl 1h
if (body.system && Array.isArray(body.system)) {
body.system = body.system.map((block, i) => {
const { cache_control, ...rest } = block;
if (i === body.system.length - 1) {
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
}
return rest;
});
}
// 2. Messages: process in optimized passes
if (body.messages && Array.isArray(body.messages)) {
const len = body.messages.length;
let filtered = [];
// Pass 1: remove cache_control + filter empty messages
for (let i = 0; i < len; i++) {
const msg = body.messages[i];
// Remove cache_control from content blocks
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
delete block.cache_control;
}
}
// Keep final assistant even if empty, otherwise check valid content
const isFinalAssistant = i === len - 1 && msg.role === "assistant";
if (isFinalAssistant || hasValidContent(msg)) {
filtered.push(msg);
}
}
// Pass 1.5: Fix tool_use/tool_result ordering
// Each tool_use must have tool_result in the NEXT message (not same message with other content)
filtered = fixToolUseOrdering(filtered);
body.messages = filtered;
// Check if thinking is enabled AND last message is from user
const lastMessage = filtered[filtered.length - 1];
const lastMessageIsUser = lastMessage?.role === "user";
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
const msg = filtered[i];
if (msg.role === "assistant" && Array.isArray(msg.content)) {
// Add cache_control to last block of first (from end) assistant with content
if (!lastAssistantProcessed && msg.content.length > 0) {
msg.content[msg.content.length - 1].cache_control = { type: "ephemeral" };
lastAssistantProcessed = true;
}
// Handle thinking blocks for Anthropic endpoints (native + compatible)
if (provider === "claude" || provider?.startsWith?.("anthropic-compatible-")) {
let hasToolUse = false;
let hasThinking = false;
// Always replace signature for all thinking blocks
for (const block of msg.content) {
if (block.type === "thinking" || block.type === "redacted_thinking") {
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
hasThinking = true;
}
if (block.type === "tool_use") hasToolUse = true;
}
// Add thinking block if thinking enabled + has tool_use but no thinking
if (thinkingEnabled && !hasThinking && hasToolUse) {
msg.content.unshift({
type: "thinking",
thinking: ".",
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
}
}
}
}
}
// 3. Tools: remove all cache_control, add only to last tool with ttl 1h
if (body.tools && Array.isArray(body.tools)) {
body.tools = body.tools.map((tool, i) => {
const { cache_control, ...rest } = tool;
if (i === body.tools.length - 1) {
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
}
return rest;
});
}
return body;
}

View File

@@ -0,0 +1,371 @@
// Gemini helper functions for translator
// Unsupported JSON Schema constraints that should be removed for Antigravity
// Reference: CLIProxyAPI/internal/util/gemini_schema.go (removeUnsupportedKeywords)
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
// Basic constraints (not supported by Gemini API)
"minLength",
"maxLength",
"exclusiveMinimum",
"exclusiveMaximum",
"pattern",
"minItems",
"maxItems",
"format",
// Claude rejects these in VALIDATED mode
"default",
"examples",
// JSON Schema meta keywords
"$schema",
"$defs",
"definitions",
"const",
"$ref",
// Object validation keywords (not supported)
"additionalProperties",
"propertyNames",
"patternProperties",
// Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf)
"anyOf",
"oneOf",
"allOf",
"not",
// Dependency keywords (not supported)
"dependencies",
"dependentSchemas",
"dependentRequired",
// Other unsupported keywords
"title",
"if",
"then",
"else",
"contentMediaType",
"contentEncoding",
// UI/Styling properties (from Cursor tools - NOT JSON Schema standard)
"cornerRadius",
"fillColor",
"fontFamily",
"fontSize",
"fontWeight",
"gap",
"padding",
"strokeColor",
"strokeThickness",
"textColor",
];
// Default safety settings
export const DEFAULT_SAFETY_SETTINGS = [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" },
];
// Convert OpenAI content to Gemini parts
export function convertOpenAIContentToParts(content) {
const parts = [];
if (typeof content === "string") {
parts.push({ text: content });
} else if (Array.isArray(content)) {
for (const item of content) {
if (item.type === "text") {
parts.push({ text: item.text });
} else if (item.type === "image_url" && item.image_url?.url?.startsWith("data:")) {
const url = item.image_url.url;
const commaIndex = url.indexOf(",");
if (commaIndex !== -1) {
const mimePart = url.substring(5, commaIndex); // skip "data:"
const data = url.substring(commaIndex + 1);
const mimeType = mimePart.split(";")[0];
parts.push({
inlineData: { mime_type: mimeType, data: data },
});
}
}
}
}
return parts;
}
// Extract text content from OpenAI content
export function extractTextContent(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
return "";
}
// Try parse JSON safely
export function tryParseJSON(str) {
if (typeof str !== "string") return str;
try {
return JSON.parse(str);
} catch {
return null;
}
}
// Generate request ID
export function generateRequestId() {
return `agent-${crypto.randomUUID()}`;
}
// Generate session ID
export function generateSessionId() {
return `-${Math.floor(Math.random() * 9000000000000000000)}`;
}
// Generate project ID
export function generateProjectId() {
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
const nouns = ["fuze", "wave", "spark", "flow", "core"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
// Helper: Remove unsupported keywords recursively from object/array
function removeUnsupportedKeywords(obj, keywords) {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
removeUnsupportedKeywords(item, keywords);
}
} else {
// Delete unsupported keys at current level
for (const keyword of keywords) {
if (keyword in obj) {
delete obj[keyword];
}
}
// Recurse into remaining values
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
removeUnsupportedKeywords(value, keywords);
}
}
}
}
// Convert const to enum
function convertConstToEnum(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.const !== undefined && !obj.enum) {
obj.enum = [obj.const];
delete obj.const;
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
convertConstToEnum(value);
}
}
}
// Convert enum values to strings (Gemini requires string enum values)
function convertEnumValuesToStrings(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.enum && Array.isArray(obj.enum)) {
obj.enum = obj.enum.map((v) => String(v));
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
convertEnumValuesToStrings(value);
}
}
}
// Merge allOf schemas
function mergeAllOf(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.allOf && Array.isArray(obj.allOf)) {
const merged = {};
for (const item of obj.allOf) {
if (item.properties) {
if (!merged.properties) merged.properties = {};
Object.assign(merged.properties, item.properties);
}
if (item.required && Array.isArray(item.required)) {
if (!merged.required) merged.required = [];
for (const req of item.required) {
if (!merged.required.includes(req)) {
merged.required.push(req);
}
}
}
}
delete obj.allOf;
if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties };
if (merged.required) obj.required = [...(obj.required || []), ...merged.required];
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
mergeAllOf(value);
}
}
}
// Select best schema from anyOf/oneOf
function selectBest(items) {
let bestIdx = 0;
let bestScore = -1;
for (let i = 0; i < items.length; i++) {
const item = items[i];
let score = 0;
const type = item.type;
if (type === "object" || item.properties) {
score = 3;
} else if (type === "array" || item.items) {
score = 2;
} else if (type && type !== "null") {
score = 1;
}
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
return bestIdx;
}
// Flatten anyOf/oneOf
function flattenAnyOfOneOf(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
const nonNullSchemas = obj.anyOf.filter((s) => s && s.type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete obj.anyOf;
Object.assign(obj, selected);
}
}
if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
const nonNullSchemas = obj.oneOf.filter((s) => s && s.type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete obj.oneOf;
Object.assign(obj, selected);
}
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
flattenAnyOfOneOf(value);
}
}
}
// Flatten type arrays
function flattenTypeArrays(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.type && Array.isArray(obj.type)) {
const nonNullTypes = obj.type.filter((t) => t !== "null");
obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
flattenTypeArrays(value);
}
}
}
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
// Reference: CLIProxyAPI/internal/util/gemini_schema.go
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
// Mutate directly (schema is only used once per request)
let cleaned = schema;
// Phase 1: Convert and prepare
convertConstToEnum(cleaned);
convertEnumValuesToStrings(cleaned);
// Phase 2: Flatten complex structures
mergeAllOf(cleaned);
flattenAnyOfOneOf(cleaned);
flattenTypeArrays(cleaned);
// Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays)
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);
// Phase 4: Cleanup required fields recursively
function cleanupRequired(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.required && Array.isArray(obj.required) && obj.properties) {
const validRequired = obj.required.filter((field) =>
Object.prototype.hasOwnProperty.call(obj.properties, field)
);
if (validRequired.length === 0) {
delete obj.required;
} else {
obj.required = validRequired;
}
}
// Recurse into nested objects
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
cleanupRequired(value);
}
}
}
cleanupRequired(cleaned);
// Phase 5: Add placeholder for empty object schemas (Antigravity requirement)
function addPlaceholders(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.type === "object") {
if (!obj.properties || Object.keys(obj.properties).length === 0) {
obj.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool",
},
};
obj.required = ["reason"];
}
}
// Recurse into nested objects
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
addPlaceholders(value);
}
}
}
addPlaceholders(cleaned);
return cleaned;
}

View File

@@ -0,0 +1,20 @@
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/constants.js";
/**
* Adjust max_tokens based on request context
* @param {object} body - Request body
* @returns {number} Adjusted max_tokens
*/
export function adjustMaxTokens(body) {
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
// Auto-increase for tool calling to prevent truncated arguments
// Tool calls with large content (like writing files) need more tokens
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
if (maxTokens < DEFAULT_MIN_TOKENS) {
maxTokens = DEFAULT_MIN_TOKENS;
}
}
return maxTokens;
}

View File

@@ -0,0 +1,143 @@
// OpenAI helper functions for translator
// Valid OpenAI content block types
export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"];
export const VALID_OPENAI_MESSAGE_TYPES = [
"text",
"image_url",
"image",
"tool_calls",
"tool_result",
];
// Filter messages to OpenAI standard format
// Remove: redacted_thinking, and other non-OpenAI blocks
// Convert: thinking blocks → reasoning_content on the message
export function filterToOpenAIFormat(body) {
if (!body.messages || !Array.isArray(body.messages)) return body;
body.messages = body.messages.map((msg) => {
// Keep tool messages as-is (OpenAI format)
if (msg.role === "tool") return msg;
// Keep assistant messages with tool_calls as-is
if (msg.role === "assistant" && msg.tool_calls) return msg;
// Handle string content
if (typeof msg.content === "string") return msg;
// Handle array content
if (Array.isArray(msg.content)) {
const filteredContent = [];
let thinkingText = null;
for (const block of msg.content) {
// Extract thinking blocks as reasoning_content (OpenAI extended thinking)
if (block.type === "thinking") {
thinkingText = block.thinking || block.text || "";
continue;
}
// Skip redacted thinking
if (block.type === "redacted_thinking") continue;
// Only keep valid OpenAI content types
if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) {
// Remove signature and cache_control fields
const { signature, cache_control, ...cleanBlock } = block;
filteredContent.push(cleanBlock);
} else if (block.type === "tool_use") {
// Convert tool_use to tool_calls format (handled separately)
continue;
} else if (block.type === "tool_result") {
// Keep tool_result but clean it
const { signature, cache_control, ...cleanBlock } = block;
filteredContent.push(cleanBlock);
}
}
// If all content was filtered, add empty text
if (filteredContent.length === 0) {
filteredContent.push({ type: "text", text: "" });
}
const result = { ...msg, content: filteredContent };
// Attach thinking as reasoning_content for OpenAI extended thinking format
if (thinkingText && msg.role === "assistant") {
result.reasoning_content = thinkingText;
}
return result;
}
return msg;
});
// Filter out messages with only empty text (but NEVER filter tool messages)
body.messages = body.messages.filter((msg) => {
// Always keep tool messages
if (msg.role === "tool") return true;
// Always keep assistant messages with tool_calls
if (msg.role === "assistant" && msg.tool_calls) return true;
if (typeof msg.content === "string") return msg.content.trim() !== "";
if (Array.isArray(msg.content)) {
return msg.content.some((b) => (b.type === "text" && b.text?.trim()) || b.type !== "text");
}
return true;
});
// Remove empty tools array (some providers like QWEN reject it)
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0) {
delete body.tools;
}
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
body.tools = body.tools
.map((tool) => {
// Already OpenAI format
if (tool.type === "function" && tool.function) return tool;
// Claude format: {name, description, input_schema}
if (tool.name && (tool.input_schema || tool.description)) {
return {
type: "function",
function: {
name: tool.name,
description: tool.description || "",
parameters: tool.input_schema || { type: "object", properties: {} },
},
};
}
// Gemini format: {functionDeclarations: [{name, description, parameters}]}
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
return tool.functionDeclarations.map((fn) => ({
type: "function",
function: {
name: fn.name,
description: fn.description || "",
parameters: fn.parameters || { type: "object", properties: {} },
},
}));
}
return tool;
})
.flat();
}
// Normalize tool_choice to OpenAI format
if (body.tool_choice && typeof body.tool_choice === "object") {
const choice = body.tool_choice;
// Claude format: {type: "auto|any|tool", name?: "..."}
if (choice.type === "auto") {
body.tool_choice = "auto";
} else if (choice.type === "any") {
body.tool_choice = "required";
} else if (choice.type === "tool" && choice.name) {
body.tool_choice = { type: "function", function: { name: choice.name } };
}
}
return body;
}

View File

@@ -0,0 +1,104 @@
/**
* Convert OpenAI Responses API format to standard chat completions format
* Responses API uses: { input: [...], instructions: "..." }
* Chat API uses: { messages: [...] }
*/
export function convertResponsesApiFormat(body) {
if (!body.input) return body;
const result = { ...body };
result.messages = [];
// Convert instructions to system message
if (body.instructions) {
result.messages.push({ role: "system", content: body.instructions });
}
// Group items by conversation turn
let currentAssistantMsg = null;
let pendingToolCalls = [];
let pendingToolResults = [];
for (const item of body.input) {
// Determine item type - Droid CLI sends role-based items without 'type' field
// Fallback: if no type but has role property, treat as message
const itemType = item.type || (item.role ? "message" : null);
if (itemType === "message") {
// Flush any pending assistant message with tool calls
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Flush pending tool results
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
pendingToolResults = [];
}
// Convert content: input_text → text, output_text → text
const content = Array.isArray(item.content)
? item.content.map((c) => {
if (c.type === "input_text") return { type: "text", text: c.text };
if (c.type === "output_text") return { type: "text", text: c.text };
return c;
})
: item.content;
result.messages.push({ role: item.role, content });
} else if (itemType === "function_call") {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
role: "assistant",
content: null,
tool_calls: [],
};
}
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: "function",
function: {
name: item.name,
arguments: item.arguments,
},
});
} else if (itemType === "function_call_output") {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Add tool result
pendingToolResults.push({
role: "tool",
tool_call_id: item.call_id,
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output),
});
} else if (itemType === "reasoning") {
// Skip reasoning items - they are for display only
continue;
}
}
// Flush remaining
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
}
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
}
// Cleanup Responses API specific fields
delete result.input;
delete result.instructions;
delete result.include;
delete result.prompt_cache_key;
delete result.store;
delete result.reasoning;
return result;
}

View File

@@ -0,0 +1,110 @@
// Tool call helper functions for translator
// Generate unique tool call ID
export function generateToolCallId() {
return `call_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
}
// Ensure all tool_calls have id field and arguments is string (some providers require it)
export function ensureToolCallIds(body) {
if (!body.messages || !Array.isArray(body.messages)) return body;
for (const msg of body.messages) {
if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (!tc.id) {
tc.id = generateToolCallId();
}
if (!tc.type) {
tc.type = "function";
}
// Ensure arguments is JSON string, not object
if (tc.function?.arguments && typeof tc.function.arguments !== "string") {
tc.function.arguments = JSON.stringify(tc.function.arguments);
}
}
}
}
return body;
}
// Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content)
export function getToolCallIds(msg) {
if (msg.role !== "assistant") return [];
const ids = [];
// OpenAI format: tool_calls array
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (tc.id) ids.push(tc.id);
}
}
// Claude format: tool_use blocks in content
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.id) {
ids.push(block.id);
}
}
}
return ids;
}
// Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content)
export function hasToolResults(msg, toolCallIds) {
if (!msg || !toolCallIds.length) return false;
// OpenAI format: role = "tool" with tool_call_id
if (msg.role === "tool" && msg.tool_call_id) {
return toolCallIds.includes(msg.tool_call_id);
}
// Claude format: tool_result blocks in user message content
if (msg.role === "user" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result" && toolCallIds.includes(block.tool_use_id)) {
return true;
}
}
}
return false;
}
// Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result
export function fixMissingToolResponses(body) {
if (!body.messages || !Array.isArray(body.messages)) return body;
const newMessages = [];
for (let i = 0; i < body.messages.length; i++) {
const msg = body.messages[i];
const nextMsg = body.messages[i + 1];
newMessages.push(msg);
// Check if this is assistant with tool_calls/tool_use
const toolCallIds = getToolCallIds(msg);
if (toolCallIds.length === 0) continue;
// Check if next message has tool_result
if (nextMsg && !hasToolResults(nextMsg, toolCallIds)) {
// Insert tool responses for each tool_call
for (const id of toolCallIds) {
// OpenAI format: role = "tool"
newMessages.push({
role: "tool",
tool_call_id: id,
content: "",
});
}
}
}
body.messages = newMessages;
return body;
}

View File

@@ -0,0 +1,292 @@
import { FORMATS } from "./formats.js";
import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.js";
import { prepareClaudeRequest } from "./helpers/claudeHelper.js";
import { filterToOpenAIFormat } from "./helpers/openaiHelper.js";
import { normalizeThinkingConfig } from "../services/provider.js";
// Registry for translators.
// NOTE: translator modules import this file and call register() at module-load time.
// Using `var` + lazy init avoids TDZ/circular-init crashes under bundlers.
var requestRegistry;
var responseRegistry;
function getRequestRegistry() {
if (!requestRegistry) requestRegistry = new Map();
return requestRegistry;
}
function getResponseRegistry() {
if (!responseRegistry) responseRegistry = new Map();
return responseRegistry;
}
function normalizeResponsesInputItem(item) {
if (typeof item === "string") {
return {
type: "message",
role: "user",
content: [{ type: "input_text", text: item }],
};
}
if (!item || typeof item !== "object") return item;
if (item.type || item.role) {
return item.type ? item : { type: "message", ...item };
}
if (typeof item.text === "string") {
return {
type: "message",
role: "user",
content: [{ type: "input_text", text: item.text }],
};
}
return item;
}
function normalizeOpenAIResponsesRequest(body) {
if (!body || typeof body !== "object") return body;
const normalized = { ...body };
if (typeof normalized.input === "string") {
normalized.input = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: normalized.input }],
},
];
return normalized;
}
if (Array.isArray(normalized.input)) {
normalized.input = normalized.input.map(normalizeResponsesInputItem);
return normalized;
}
if (normalized.input && typeof normalized.input === "object") {
normalized.input = [normalizeResponsesInputItem(normalized.input)];
return normalized;
}
return normalized;
}
// Register translator (called by each translator module on import)
export function register(from, to, requestFn, responseFn) {
const key = `${from}:${to}`;
if (requestFn) {
getRequestRegistry().set(key, requestFn);
}
if (responseFn) {
getResponseRegistry().set(key, responseFn);
}
}
// Translator modules self-register via register() on import
import "./request/claude-to-openai.js";
import "./request/openai-to-claude.js";
import "./request/gemini-to-openai.js";
import "./request/openai-to-gemini.js";
import "./request/antigravity-to-openai.js";
import "./request/openai-responses.js";
import "./request/openai-to-kiro.js";
import "./request/openai-to-cursor.js";
import "./request/claude-to-gemini.js";
import "./response/claude-to-openai.js";
import "./response/openai-to-claude.js";
import "./response/gemini-to-openai.js";
import "./response/gemini-to-claude.js";
import "./response/openai-to-antigravity.js";
import "./response/openai-responses.js";
import "./response/kiro-to-openai.js";
import "./response/cursor-to-openai.js";
// Translate request: source -> openai -> target
export function translateRequest(
sourceFormat,
targetFormat,
model,
body,
stream = true,
credentials = null,
provider = null,
reqLogger = null
) {
let result = body;
// Normalize thinking config: remove if lastMessage is not user
normalizeThinkingConfig(result);
// Always ensure tool_calls have id (some providers require it)
ensureToolCallIds(result);
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// Check for direct translation path first (e.g., Claude → Gemini)
const directKey = `${sourceFormat}:${targetFormat}`;
const directTranslator = getRequestRegistry().get(directKey);
if (directTranslator && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
result = directTranslator(model, result, stream, credentials);
} else {
// Fallback: hub-and-spoke via OpenAI
// Step 1: source -> openai (if source is not openai)
if (sourceFormat !== FORMATS.OPENAI) {
const toOpenAI = getRequestRegistry().get(`${sourceFormat}:${FORMATS.OPENAI}`);
if (toOpenAI) {
result = toOpenAI(model, result, stream, credentials);
// Log OpenAI intermediate format
reqLogger?.logOpenAIRequest?.(result);
}
}
// Step 2: openai -> target (if target is not openai)
if (targetFormat !== FORMATS.OPENAI) {
const fromOpenAI = getRequestRegistry().get(`${FORMATS.OPENAI}:${targetFormat}`);
if (fromOpenAI) {
result = fromOpenAI(model, result, stream, credentials);
}
}
}
}
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
if (targetFormat === FORMATS.OPENAI) {
result = filterToOpenAIFormat(result);
}
// Final step: prepare request for Claude format endpoints
if (targetFormat === FORMATS.CLAUDE) {
result = prepareClaudeRequest(result, provider);
}
// Normalize openai-responses input shape for providers that require list input.
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
result = normalizeOpenAIResponsesRequest(result);
}
return result;
}
// Translate response chunk: target -> openai -> source
export function translateResponse(targetFormat, sourceFormat, chunk, state) {
// If same format, return as-is
if (sourceFormat === targetFormat) {
return [chunk];
}
let results = [chunk];
let openaiResults = null; // Store OpenAI intermediate results
// Check for direct translation path first (e.g., Gemini → Claude)
const directKey = `${targetFormat}:${sourceFormat}`;
const directTranslator = getResponseRegistry().get(directKey);
if (directTranslator && targetFormat !== FORMATS.OPENAI && sourceFormat !== FORMATS.OPENAI) {
const converted = directTranslator(chunk, state);
if (converted) {
results = Array.isArray(converted) ? converted : [converted];
} else {
results = [];
}
return results;
}
// Fallback: hub-and-spoke via OpenAI
// Step 1: target -> openai (if target is not openai)
if (targetFormat !== FORMATS.OPENAI) {
const toOpenAI = getResponseRegistry().get(`${targetFormat}:${FORMATS.OPENAI}`);
if (toOpenAI) {
results = [];
const converted = toOpenAI(chunk, state);
if (converted) {
results = Array.isArray(converted) ? converted : [converted];
openaiResults = results; // Store OpenAI intermediate
}
}
}
// Step 2: openai -> source (if source is not openai)
if (sourceFormat !== FORMATS.OPENAI) {
const fromOpenAI = getResponseRegistry().get(`${FORMATS.OPENAI}:${sourceFormat}`);
if (fromOpenAI) {
const finalResults = [];
for (const r of results) {
const converted = fromOpenAI(r, state);
if (converted) {
finalResults.push(...(Array.isArray(converted) ? converted : [converted]));
}
}
results = finalResults;
}
}
// Attach OpenAI intermediate results for logging
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
results._openaiIntermediate = openaiResults;
}
return results;
}
// Check if translation needed
export function needsTranslation(sourceFormat, targetFormat) {
return sourceFormat !== targetFormat;
}
// Initialize state for streaming response based on format
export function initState(sourceFormat) {
// Base state for all formats
const base = {
messageId: null,
model: null,
textBlockStarted: false,
thinkingBlockStarted: false,
inThinkingBlock: false,
currentBlockIndex: null,
toolCalls: new Map(),
finishReason: null,
finishReasonSent: false,
usage: null,
contentBlockIndex: -1,
};
// Add openai-responses specific fields
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return {
...base,
seq: 0,
responseId: `resp_${Date.now()}`,
created: Math.floor(Date.now() / 1000),
started: false,
msgTextBuf: {},
msgItemAdded: {},
msgContentAdded: {},
msgItemDone: {},
reasoningId: "",
reasoningIndex: -1,
reasoningBuf: "",
reasoningPartAdded: false,
reasoningDone: false,
inThinking: false,
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcArgsDone: {},
funcItemDone: {},
completedSent: false,
};
}
return base;
}
// Initialize all translators (no-op, kept for backward compatibility)
export function initTranslators() {}

View File

@@ -0,0 +1,231 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
// Convert Antigravity request to OpenAI format
// Antigravity body: { project, model, userAgent, requestType, requestId, request: { contents, systemInstruction, tools, toolConfig, generationConfig, sessionId } }
export function antigravityToOpenAIRequest(model, body, stream) {
const req = body.request || body;
const result = {
model: model,
messages: [],
stream: stream,
};
// Generation config
if (req.generationConfig) {
const config = req.generationConfig;
if (config.maxOutputTokens) {
const tempBody = { max_tokens: config.maxOutputTokens, tools: req.tools };
result.max_tokens = adjustMaxTokens(tempBody);
}
if (config.temperature !== undefined) {
result.temperature = config.temperature;
}
if (config.topP !== undefined) {
result.top_p = config.topP;
}
if (config.topK !== undefined) {
result.top_k = config.topK;
}
// Thinking config → reasoning_effort
if (config.thinkingConfig) {
const budget = config.thinkingConfig.thinkingBudget || 0;
if (budget > 0) {
if (budget <= 2048) {
result.reasoning_effort = "low";
} else if (budget <= 16384) {
result.reasoning_effort = "medium";
} else {
result.reasoning_effort = "high";
}
}
}
}
// System instruction
if (req.systemInstruction) {
const systemText = extractText(req.systemInstruction);
if (systemText) {
result.messages.push({ role: "system", content: systemText });
}
}
// Convert contents to messages
if (req.contents && Array.isArray(req.contents)) {
for (const content of req.contents) {
const converted = convertContent(content);
if (converted) {
if (Array.isArray(converted)) {
result.messages.push(...converted);
} else {
result.messages.push(converted);
}
}
}
}
// Tools
if (req.tools && Array.isArray(req.tools)) {
result.tools = [];
for (const tool of req.tools) {
if (tool.functionDeclarations) {
for (const func of tool.functionDeclarations) {
result.tools.push({
type: "function",
function: {
name: func.name,
description: func.description || "",
parameters: normalizeSchemaTypes(func.parameters) || {
type: "object",
properties: {},
},
},
});
}
}
}
}
return result;
}
// Recursively convert Antigravity schema types (OBJECT, STRING, etc.) to lowercase
function normalizeSchemaTypes(schema) {
if (!schema || typeof schema !== "object") return schema;
const result = Array.isArray(schema) ? [...schema] : { ...schema };
if (typeof result.type === "string") {
result.type = result.type.toLowerCase();
}
if (result.properties) {
const normalized = {};
for (const [key, val] of Object.entries(result.properties)) {
normalized[key] = normalizeSchemaTypes(val);
}
result.properties = normalized;
}
if (result.items) {
result.items = normalizeSchemaTypes(result.items);
}
return result;
}
// Convert Antigravity content to OpenAI message
// Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData
function convertContent(content) {
const role =
content.role === "model" ? "assistant" : content.role === "user" ? "user" : content.role;
if (!content.parts || !Array.isArray(content.parts)) {
return null;
}
const textParts = [];
const toolCalls = [];
const toolResults = [];
let reasoningContent = "";
for (const part of content.parts) {
// Thinking content (thought: true)
if (part.thought === true && part.text) {
reasoningContent += part.text;
continue;
}
// Text with thoughtSignature = regular text after thinking
if (part.thoughtSignature && part.text !== undefined) {
textParts.push({ type: "text", text: part.text });
continue;
}
// Regular text
if (part.text !== undefined) {
textParts.push({ type: "text", text: part.text });
}
// Inline data (images)
if (part.inlineData) {
textParts.push({
type: "image_url",
image_url: {
url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
},
});
}
// Function call
if (part.functionCall) {
toolCalls.push({
id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: "function",
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args || {}),
},
});
}
// Function response → collect all, each becomes a separate tool message
if (part.functionResponse) {
toolResults.push({
role: "tool",
tool_call_id: part.functionResponse.id || part.functionResponse.name,
content: JSON.stringify(
part.functionResponse.response?.result || part.functionResponse.response || {}
),
});
}
}
// Content with only functionResponses → return array of tool messages
if (toolResults.length > 0) {
return toolResults;
}
// Assistant with tool calls
if (toolCalls.length > 0) {
const msg = { role: "assistant" };
if (textParts.length > 0) {
msg.content =
textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts;
}
if (reasoningContent) {
msg.reasoning_content = reasoningContent;
}
msg.tool_calls = toolCalls;
return msg;
}
// Regular message
if (textParts.length > 0 || reasoningContent) {
const msg = { role };
if (textParts.length > 0) {
msg.content =
textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts;
}
if (reasoningContent) {
msg.reasoning_content = reasoningContent;
}
return msg;
}
return null;
}
// Extract text from systemInstruction
function extractText(instruction) {
if (typeof instruction === "string") return instruction;
if (instruction.parts && Array.isArray(instruction.parts)) {
return instruction.parts.map((p) => p.text || "").join("");
}
return "";
}
// Register
register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, antigravityToOpenAIRequest, null);

View File

@@ -0,0 +1,173 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { DEFAULT_SAFETY_SETTINGS, tryParseJSON } from "../helpers/geminiHelper.js";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
/**
* Direct Claude → Gemini request translator.
* Converts Claude Messages API body directly to Gemini format,
* skipping the OpenAI hub intermediate step.
*/
export function claudeToGeminiRequest(model, body, stream) {
const result = {
model: model,
contents: [],
generationConfig: {},
safetySettings: DEFAULT_SAFETY_SETTINGS,
};
// ── Generation config ──────────────────────────────────────────
if (body.temperature !== undefined) {
result.generationConfig.temperature = body.temperature;
}
if (body.top_p !== undefined) {
result.generationConfig.topP = body.top_p;
}
if (body.top_k !== undefined) {
result.generationConfig.topK = body.top_k;
}
if (body.max_tokens !== undefined) {
result.generationConfig.maxOutputTokens = body.max_tokens;
}
// ── System instruction ─────────────────────────────────────────
if (body.system) {
let systemText;
if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
} else {
systemText = String(body.system);
}
if (systemText) {
result.systemInstruction = {
role: "user",
parts: [{ text: systemText }],
};
}
}
// ── Build tool_use name lookup (for tool_result matching) ──────
const toolUseNames = {};
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.id && block.name) {
toolUseNames[block.id] = block.name;
}
}
}
}
}
// ── Convert messages ───────────────────────────────────────────
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
const parts = [];
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
switch (block.type) {
case "text":
if (block.text) parts.push({ text: block.text });
break;
case "thinking":
// Preserve thinking blocks as thought parts
if (block.thinking) {
parts.push({ thought: true, text: block.thinking });
parts.push({ thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, text: "" });
}
break;
case "tool_use":
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
functionCall: {
id: block.id,
name: block.name,
args: block.input || {},
},
});
break;
case "tool_result": {
let content = block.content;
if (Array.isArray(content)) {
content = content
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
.join("\n");
}
let parsedContent = tryParseJSON(content);
if (parsedContent === null) {
parsedContent = { result: content };
} else if (typeof parsedContent !== "object") {
parsedContent = { result: parsedContent };
}
parts.push({
functionResponse: {
id: block.tool_use_id,
name: toolUseNames[block.tool_use_id] || "unknown",
response: { result: parsedContent },
},
});
break;
}
case "image":
// Base64 image → Gemini inlineData
if (block.source?.type === "base64") {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
},
});
}
break;
}
}
} else if (typeof msg.content === "string" && msg.content) {
parts.push({ text: msg.content });
}
if (parts.length > 0) {
// Map Claude roles to Gemini roles
const geminiRole = msg.role === "assistant" ? "model" : "user";
result.contents.push({ role: geminiRole, parts });
}
}
}
// ── Convert tools ──────────────────────────────────────────────
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
const functionDeclarations = [];
for (const tool of body.tools) {
if (tool.name) {
functionDeclarations.push({
name: tool.name,
description: tool.description || "",
parameters: tool.input_schema || { type: "object", properties: {} },
});
}
}
if (functionDeclarations.length > 0) {
result.tools = [{ functionDeclarations }];
}
}
// ── Thinking config ────────────────────────────────────────────
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
result.generationConfig.thinkingConfig = {
thinkingBudget: body.thinking.budget_tokens,
include_thoughts: true,
};
}
return result;
}
// Register direct path only for plain Gemini API.
// Gemini CLI / Antigravity require Cloud Code envelope wrapping,
// so they must use the existing hub path (Claude -> OpenAI -> target).
register(FORMATS.CLAUDE, FORMATS.GEMINI, claudeToGeminiRequest, null);

View File

@@ -0,0 +1,232 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
// Convert Claude request to OpenAI format
export function claudeToOpenAIRequest(model, body, stream) {
const result = {
model: model,
messages: [],
stream: stream,
};
// Max tokens
if (body.max_tokens) {
result.max_tokens = adjustMaxTokens(body);
}
// Temperature
if (body.temperature !== undefined) {
result.temperature = body.temperature;
}
// System message
if (body.system) {
const systemContent = Array.isArray(body.system)
? body.system.map((s) => s.text || "").join("\n")
: body.system;
if (systemContent) {
result.messages.push({
role: "system",
content: systemContent,
});
}
}
// Convert messages
if (body.messages && Array.isArray(body.messages)) {
for (let i = 0; i < body.messages.length; i++) {
const msg = body.messages[i];
const converted = convertClaudeMessage(msg);
if (converted) {
// Handle array of messages (multiple tool results)
if (Array.isArray(converted)) {
result.messages.push(...converted);
} else {
result.messages.push(converted);
}
}
}
}
// Fix missing tool responses - OpenAI requires every tool_call to have a response
fixMissingToolResponses(result.messages);
// Tools
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.input_schema || { type: "object", properties: {} },
},
}));
}
// Tool choice
if (body.tool_choice) {
result.tool_choice = convertToolChoice(body.tool_choice);
}
return result;
}
// Fix missing tool responses - add empty responses for tool_calls without responses
function fixMissingToolResponses(messages) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
const toolCallIds = msg.tool_calls.map((tc) => tc.id);
// Collect all tool response IDs that IMMEDIATELY follow this assistant message
const respondedIds = new Set();
let insertPosition = i + 1;
for (let j = i + 1; j < messages.length; j++) {
const nextMsg = messages[j];
if (nextMsg.role === "tool" && nextMsg.tool_call_id) {
respondedIds.add(nextMsg.tool_call_id);
insertPosition = j + 1;
} else {
break;
}
}
// Find missing responses and insert them
const missingIds = toolCallIds.filter((id) => !respondedIds.has(id));
if (missingIds.length > 0) {
const missingResponses = missingIds.map((id) => ({
role: "tool",
tool_call_id: id,
content: "[No response received]",
}));
messages.splice(insertPosition, 0, ...missingResponses);
i = insertPosition + missingResponses.length - 1;
}
}
}
}
// Convert single Claude message - returns single message or array of messages
function convertClaudeMessage(msg) {
const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant";
// Simple string content
if (typeof msg.content === "string") {
return { role, content: msg.content };
}
// Array content
if (Array.isArray(msg.content)) {
const parts = [];
const toolCalls = [];
const toolResults = [];
for (const block of msg.content) {
switch (block.type) {
case "text":
parts.push({ type: "text", text: block.text });
break;
case "image":
if (block.source?.type === "base64") {
parts.push({
type: "image_url",
image_url: {
url: `data:${block.source.media_type};base64,${block.source.data}`,
},
});
}
break;
case "tool_use":
toolCalls.push({
id: block.id,
type: "function",
function: {
name: block.name,
arguments: JSON.stringify(block.input || {}),
},
});
break;
case "tool_result":
let resultContent = "";
if (typeof block.content === "string") {
resultContent = block.content;
} else if (Array.isArray(block.content)) {
resultContent =
block.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n") || JSON.stringify(block.content);
} else if (block.content) {
resultContent = JSON.stringify(block.content);
}
toolResults.push({
role: "tool",
tool_call_id: block.tool_use_id,
content: resultContent,
});
break;
}
}
// If has tool results, return array of tool messages
if (toolResults.length > 0) {
if (parts.length > 0) {
const textContent = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
return [...toolResults, { role: "user", content: textContent }];
}
return toolResults;
}
// If has tool calls, return assistant message with tool_calls
if (toolCalls.length > 0) {
const result = { role: "assistant" };
if (parts.length > 0) {
result.content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
}
result.tool_calls = toolCalls;
return result;
}
// Return content
if (parts.length > 0) {
return {
role,
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts,
};
}
// Empty content array
if (msg.content.length === 0) {
return { role, content: "" };
}
}
return null;
}
// Convert tool choice
function convertToolChoice(choice) {
if (!choice) return "auto";
if (typeof choice === "string") return choice;
switch (choice.type) {
case "auto":
return "auto";
case "any":
return "required";
case "tool":
return { type: "function", function: { name: choice.name } };
default:
return "auto";
}
}
// Register
register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAIRequest, null);

View File

@@ -0,0 +1,148 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
// Convert Gemini request to OpenAI format
export function geminiToOpenAIRequest(model, body, stream) {
const result = {
model: model,
messages: [],
stream: stream,
};
// Generation config
if (body.generationConfig) {
const config = body.generationConfig;
if (config.maxOutputTokens) {
const tempBody = { max_tokens: config.maxOutputTokens, tools: body.tools };
result.max_tokens = adjustMaxTokens(tempBody);
}
if (config.temperature !== undefined) {
result.temperature = config.temperature;
}
if (config.topP !== undefined) {
result.top_p = config.topP;
}
}
// System instruction
if (body.systemInstruction) {
const systemText = extractGeminiText(body.systemInstruction);
if (systemText) {
result.messages.push({
role: "system",
content: systemText,
});
}
}
// Convert contents to messages
if (body.contents && Array.isArray(body.contents)) {
for (const content of body.contents) {
const converted = convertGeminiContent(content);
if (converted) {
result.messages.push(converted);
}
}
}
// Tools
if (body.tools && Array.isArray(body.tools)) {
result.tools = [];
for (const tool of body.tools) {
if (tool.functionDeclarations) {
for (const func of tool.functionDeclarations) {
result.tools.push({
type: "function",
function: {
name: func.name,
description: func.description || "",
parameters: func.parameters || { type: "object", properties: {} },
},
});
}
}
}
}
return result;
}
// Convert Gemini content to OpenAI message
function convertGeminiContent(content) {
const role = content.role === "user" ? "user" : "assistant";
if (!content.parts || !Array.isArray(content.parts)) {
return null;
}
const parts = [];
const toolCalls = [];
for (const part of content.parts) {
if (part.text !== undefined) {
parts.push({ type: "text", text: part.text });
}
if (part.inlineData) {
parts.push({
type: "image_url",
image_url: {
url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
},
});
}
if (part.functionCall) {
toolCalls.push({
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: "function",
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args || {}),
},
});
}
if (part.functionResponse) {
return {
role: "tool",
tool_call_id: part.functionResponse.id || part.functionResponse.name,
content: JSON.stringify(
part.functionResponse.response?.result || part.functionResponse.response || {}
),
};
}
}
if (toolCalls.length > 0) {
const result = { role: "assistant" };
if (parts.length > 0) {
result.content = parts.length === 1 ? parts[0].text : parts;
}
result.tool_calls = toolCalls;
return result;
}
if (parts.length > 0) {
return {
role,
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts,
};
}
return null;
}
// Extract text from Gemini content
function extractGeminiText(content) {
if (typeof content === "string") return content;
if (content.parts && Array.isArray(content.parts)) {
return content.parts.map((p) => p.text || "").join("");
}
return "";
}
// Register
register(FORMATS.GEMINI, FORMATS.OPENAI, geminiToOpenAIRequest, null);
register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, geminiToOpenAIRequest, null);

View File

@@ -0,0 +1,307 @@
/**
* Translator: OpenAI Responses API → OpenAI Chat Completions
*
* Responses API uses: { input: [...], instructions: "..." }
* Chat API uses: { messages: [...] }
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Convert OpenAI Responses API request to OpenAI Chat Completions format
*/
export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) {
if (!body.input) return body;
// Validate unsupported features — return clear errors instead of silent failure
const UNSUPPORTED_TOOLS = ["file_search", "code_interpreter", "web_search_preview"];
if (body.tools?.length) {
for (const tool of body.tools) {
if (UNSUPPORTED_TOOLS.includes(tool.type)) {
const error = new Error(
`Unsupported Responses API feature: ${tool.type} tool type is not supported by omniroute`
);
error.statusCode = 400;
error.errorType = "unsupported_feature";
throw error;
}
}
}
if (body.background) {
const error = new Error(
"Unsupported Responses API feature: background mode is not supported by omniroute"
);
error.statusCode = 400;
error.errorType = "unsupported_feature";
throw error;
}
const result = { ...body };
result.messages = [];
// Convert instructions to system message
if (body.instructions) {
result.messages.push({ role: "system", content: body.instructions });
}
// Group items by conversation turn
let currentAssistantMsg = null;
let pendingToolResults = [];
for (const item of body.input) {
// Determine item type - Droid CLI sends role-based items without 'type' field
// Fallback: if no type but has role property, treat as message
const itemType = item.type || (item.role ? "message" : null);
if (itemType === "message") {
// Flush any pending assistant message with tool calls
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Flush pending tool results
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
pendingToolResults = [];
}
// Convert content: input_text → text, output_text → text
const content = Array.isArray(item.content)
? item.content.map((c) => {
if (c.type === "input_text") return { type: "text", text: c.text };
if (c.type === "output_text") return { type: "text", text: c.text };
return c;
})
: item.content;
result.messages.push({ role: item.role, content });
} else if (itemType === "function_call") {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
role: "assistant",
content: null,
tool_calls: [],
};
}
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: "function",
function: {
name: item.name,
arguments: item.arguments,
},
});
} else if (itemType === "function_call_output") {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Flush any pending tool results first
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
pendingToolResults = [];
}
// Add tool result immediately
result.messages.push({
role: "tool",
tool_call_id: item.call_id,
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output),
});
} else if (itemType === "reasoning") {
// Skip reasoning items - they are for display only
continue;
}
}
// Flush remaining
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
}
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
}
// Convert tools format
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map((tool) => {
if (tool.function) return tool;
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
strict: tool.strict,
},
};
});
}
// Cleanup Responses API specific fields
delete result.input;
delete result.instructions;
delete result.include;
delete result.prompt_cache_key;
delete result.store;
delete result.reasoning;
return result;
}
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) {
const result = {
model,
input: [],
stream: true,
store: false,
};
// Extract system message as instructions
let hasSystemMessage = false;
const messages = body.messages || [];
for (const msg of messages) {
if (msg.role === "system") {
// Use first system message as instructions
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue; // Skip system messages in input
}
// Convert user messages
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? [{ type: "input_text", text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map((c) => {
if (c.type === "text") return { type: "input_text", text: c.text };
if (c.type === "image_url") return c; // Pass through image content
return c;
})
: [{ type: "input_text", text: "" }];
result.input.push({
type: "message",
role: "user",
content,
});
}
// Convert assistant messages
if (msg.role === "assistant") {
// Add reasoning/thinking content BEFORE the assistant output
if (msg.reasoning_content) {
result.input.push({
type: "reasoning",
id: `reasoning_${result.input.length}`,
summary: [{ type: "summary_text", text: msg.reasoning_content }],
});
}
// Handle thinking blocks in array content
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "thinking" || block.type === "redacted_thinking") {
result.input.push({
type: "reasoning",
id: `reasoning_${result.input.length}`,
summary: [{ type: "summary_text", text: block.thinking || block.data || "..." }],
});
}
}
}
// Build the assistant output content
const outputContent = [];
if (typeof msg.content === "string" && msg.content) {
outputContent.push({ type: "output_text", text: msg.content });
} else if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c.type === "text" && c.text) {
outputContent.push({ type: "output_text", text: c.text });
} else if (c.type === "thinking" || c.type === "redacted_thinking") {
// Already handled above as reasoning items
continue;
} else if (c.type !== "thinking" && c.type !== "redacted_thinking") {
outputContent.push(c);
}
}
}
// Only add the assistant message if there's actual content
if (outputContent.length > 0) {
result.input.push({
type: "message",
role: "assistant",
content: outputContent,
});
}
// Convert tool_calls to function_call items
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
result.input.push({
type: "function_call",
call_id: tc.id,
name: tc.function?.name || "",
arguments: tc.function?.arguments || "{}",
});
}
}
}
// Convert tool results
if (msg.role === "tool") {
result.input.push({
type: "function_call_output",
call_id: msg.tool_call_id,
output: msg.content,
});
}
}
// If no system message, leave instructions empty
if (!hasSystemMessage) {
result.instructions = "";
}
// Convert tools format
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map((tool) => {
if (tool.type === "function") {
return {
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict,
};
}
return tool;
});
}
// Pass through other relevant fields
if (body.temperature !== undefined) result.temperature = body.temperature;
if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
if (body.top_p !== undefined) result.top_p = body.top_p;
return result;
}
// Register both directions
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);

View File

@@ -0,0 +1,353 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.js";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js";
// Prefix for Claude OAuth tool names to avoid conflicts
const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_";
// Convert OpenAI request to Claude format
export function openaiToClaudeRequest(model, body, stream) {
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
const toolNameMap = new Map();
const result = {
model: model,
max_tokens: adjustMaxTokens(body),
stream: stream,
};
// Temperature
if (body.temperature !== undefined) {
result.temperature = body.temperature;
}
// Messages
result.messages = [];
const systemParts = [];
if (body.messages && Array.isArray(body.messages)) {
// Extract system messages
for (const msg of body.messages) {
if (msg.role === "system") {
systemParts.push(
typeof msg.content === "string" ? msg.content : extractTextContent(msg.content)
);
}
}
// Filter out system messages for separate processing
const nonSystemMessages = body.messages.filter((m) => m.role !== "system");
// Process messages with merging logic
// CRITICAL: tool_result must be in separate message immediately after tool_use
let currentRole = undefined;
let currentParts = [];
const flushCurrentMessage = () => {
if (currentRole && currentParts.length > 0) {
result.messages.push({ role: currentRole, content: currentParts });
currentParts = [];
}
};
for (const msg of nonSystemMessages) {
const newRole = msg.role === "user" || msg.role === "tool" ? "user" : "assistant";
const blocks = getContentBlocksFromMessage(msg, toolNameMap);
const hasToolUse = blocks.some((b) => b.type === "tool_use");
const hasToolResult = blocks.some((b) => b.type === "tool_result");
// Separate tool_result from other content
if (hasToolResult) {
const toolResultBlocks = blocks.filter((b) => b.type === "tool_result");
const otherBlocks = blocks.filter((b) => b.type !== "tool_result");
flushCurrentMessage();
if (toolResultBlocks.length > 0) {
result.messages.push({ role: "user", content: toolResultBlocks });
}
if (otherBlocks.length > 0) {
currentRole = newRole;
currentParts.push(...otherBlocks);
}
continue;
}
if (currentRole !== newRole) {
flushCurrentMessage();
currentRole = newRole;
}
currentParts.push(...blocks);
if (hasToolUse) {
flushCurrentMessage();
}
}
flushCurrentMessage();
// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
const message = result.messages[i];
if (
message.role === "assistant" &&
Array.isArray(message.content) &&
message.content.length > 0
) {
const lastBlock = message.content[message.content.length - 1];
if (lastBlock) {
lastBlock.cache_control = { type: "ephemeral" };
break;
}
}
}
}
// System with Claude Code prompt and cache_control
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
if (systemParts.length > 0) {
const systemText = systemParts.join("\n");
result.system = [
claudeCodePrompt,
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } },
];
} else {
result.system = [claudeCodePrompt];
}
// Tools - convert from OpenAI format to Claude format with prefix for OAuth
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map((tool) => {
const toolData = tool.type === "function" && tool.function ? tool.function : tool;
const originalName = toolData.name;
// Claude OAuth requires prefixed tool names to avoid conflicts
const toolName = CLAUDE_OAUTH_TOOL_PREFIX + originalName;
// Store mapping for response translation (prefixed → original)
toolNameMap.set(toolName, originalName);
return {
name: toolName,
description: toolData.description || "",
input_schema: toolData.parameters ||
toolData.input_schema || { type: "object", properties: {}, required: [] },
};
});
if (result.tools.length > 0) {
result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" };
}
}
// Tool choice
if (body.tool_choice) {
result.tool_choice = convertOpenAIToolChoice(body.tool_choice);
}
// Thinking configuration
if (body.thinking) {
result.thinking = {
type: body.thinking.type || "enabled",
...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }),
...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }),
};
}
// Attach toolNameMap to result for response translation
if (toolNameMap.size > 0) {
result._toolNameMap = toolNameMap;
}
return result;
}
// Get content blocks from single message
function getContentBlocksFromMessage(msg, toolNameMap = new Map()) {
const blocks = [];
if (msg.role === "tool") {
blocks.push({
type: "tool_result",
tool_use_id: msg.tool_call_id,
content: msg.content,
});
} else if (msg.role === "user") {
if (typeof msg.content === "string") {
if (msg.content) {
blocks.push({ type: "text", text: msg.content });
}
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
blocks.push({ type: "text", text: part.text });
} else if (part.type === "tool_result") {
blocks.push({
type: "tool_result",
tool_use_id: part.tool_use_id,
content: part.content,
...(part.is_error && { is_error: part.is_error }),
});
} else if (part.type === "image_url") {
const url = part.image_url.url;
const match = url.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
blocks.push({
type: "image",
source: { type: "base64", media_type: match[1], data: match[2] },
});
}
} else if (part.type === "image" && part.source) {
blocks.push({ type: "image", source: part.source });
}
}
}
} else if (msg.role === "assistant") {
// Add reasoning_content as thinking block (OpenAI extended thinking format)
if (msg.reasoning_content) {
blocks.push({
type: "thinking",
thinking: msg.reasoning_content,
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
}
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
blocks.push({ type: "text", text: part.text });
} else if (part.type === "thinking" || part.type === "redacted_thinking") {
// Preserve thinking blocks with signature
blocks.push({
...part,
signature: part.signature || DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
} else if (part.type === "tool_use") {
// Tool name already has prefix from tool declarations, keep as-is
blocks.push({ type: "tool_use", id: part.id, name: part.name, input: part.input });
}
}
} else if (msg.content) {
const text = typeof msg.content === "string" ? msg.content : extractTextContent(msg.content);
if (text) {
blocks.push({ type: "text", text });
}
}
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (tc.type === "function") {
// Apply prefix to tool name
const toolName = CLAUDE_OAUTH_TOOL_PREFIX + tc.function.name;
blocks.push({
type: "tool_use",
id: tc.id,
name: toolName,
input: tryParseJSON(tc.function.arguments),
});
}
}
}
}
return blocks;
}
// Convert OpenAI tool choice to Claude format
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 };
}
return { type: "auto" };
}
// Extract text from content
function extractTextContent(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
}
return "";
}
// Try parse JSON
function tryParseJSON(str) {
if (typeof str !== "string") return str;
try {
return JSON.parse(str);
} catch {
return str;
}
}
// OpenAI -> Claude format for Antigravity (without system prompt modifications)
function openaiToClaudeRequestForAntigravity(model, body, stream) {
const result = openaiToClaudeRequest(model, body, stream);
// Remove Claude Code system prompt, keep only user's system messages
if (result.system && Array.isArray(result.system)) {
result.system = result.system.filter(
(block) => !block.text || !block.text.includes("You are Claude Code")
);
if (result.system.length === 0) {
delete result.system;
}
}
// Strip prefix from tool names for Antigravity (doesn't use Claude OAuth)
if (result.tools && Array.isArray(result.tools)) {
result.tools = result.tools.map((tool) => {
if (tool.name && tool.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
return {
...tool,
name: tool.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length),
};
}
return tool;
});
}
// Strip prefix from tool_use in messages
if (result.messages && Array.isArray(result.messages)) {
result.messages = result.messages.map((msg) => {
if (!msg.content || !Array.isArray(msg.content)) {
return msg;
}
const updatedContent = msg.content.map((block) => {
if (
block.type === "tool_use" &&
block.name &&
block.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)
) {
return {
...block,
name: block.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length),
};
}
return block;
});
return { ...msg, content: updatedContent };
});
}
return result;
}
// Export for use in other translators
export { openaiToClaudeRequestForAntigravity };
// Register
register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaudeRequest, null);

View File

@@ -0,0 +1,115 @@
/**
* OpenAI to Cursor Request Translator
* Converts OpenAI messages to Cursor simple format
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Convert OpenAI messages to Cursor format with native tool_results support
* - system → user with [System Instructions] prefix
* - tool → accumulate into tool_results array for next user/assistant message
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
*/
function convertMessages(messages) {
const result = [];
let pendingToolResults = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "system") {
result.push({
role: "user",
content: `[System Instructions]\n${msg.content}`,
});
continue;
}
if (msg.role === "tool") {
let toolContent = "";
if (typeof msg.content === "string") {
toolContent = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
toolContent += part.text;
}
}
}
const toolName = msg.name || "tool";
const toolCallId = msg.tool_call_id || "";
// Accumulate tool result
pendingToolResults.push({
tool_call_id: toolCallId,
name: toolName,
index: pendingToolResults.length,
raw_args: toolContent,
});
continue;
}
if (msg.role === "user" || msg.role === "assistant") {
let content = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
content += part.text;
}
}
}
// Keep tool_calls structure for assistant messages
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
const assistantMsg = { role: "assistant" };
if (content) {
assistantMsg.content = content;
}
assistantMsg.tool_calls = msg.tool_calls;
// Attach pending tool results to assistant message with tool_calls
if (pendingToolResults.length > 0) {
assistantMsg.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(assistantMsg);
} else if (content || pendingToolResults.length > 0) {
const msgObj = {
role: msg.role,
content: content || "",
};
// Attach pending tool results to this message
if (pendingToolResults.length > 0) {
msgObj.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(msgObj);
}
}
}
return result;
}
/**
* Transform OpenAI request to Cursor format
* Returns modified body with converted messages
*/
export function buildCursorRequest(model, body, stream, credentials) {
const messages = convertMessages(body.messages || []);
return {
...body,
messages,
};
}
register(FORMATS.OPENAI, FORMATS.CURSOR, buildCursorRequest, null);

View File

@@ -0,0 +1,424 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.js";
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
function generateUUID() {
return crypto.randomUUID();
}
import {
DEFAULT_SAFETY_SETTINGS,
convertOpenAIContentToParts,
extractTextContent,
tryParseJSON,
generateRequestId,
generateSessionId,
generateProjectId,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.js";
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(model, body, stream) {
const result = {
model: model,
contents: [],
generationConfig: {},
safetySettings: DEFAULT_SAFETY_SETTINGS,
};
// Generation config
if (body.temperature !== undefined) {
result.generationConfig.temperature = body.temperature;
}
if (body.top_p !== undefined) {
result.generationConfig.topP = body.top_p;
}
if (body.top_k !== undefined) {
result.generationConfig.topK = body.top_k;
}
if (body.max_tokens !== undefined) {
result.generationConfig.maxOutputTokens = body.max_tokens;
}
// Build tool_call_id -> name map
const tcID2Name = {};
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role === "assistant" && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.type === "function" && tc.id && tc.function?.name) {
tcID2Name[tc.id] = tc.function.name;
}
}
}
}
}
// Build tool responses cache
const toolResponses = {};
if (body.messages && Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role === "tool" && msg.tool_call_id) {
toolResponses[msg.tool_call_id] = msg.content;
}
}
}
// Convert messages
if (body.messages && Array.isArray(body.messages)) {
for (let i = 0; i < body.messages.length; i++) {
const msg = body.messages[i];
const role = msg.role;
const content = msg.content;
if (role === "system" && body.messages.length > 1) {
result.systemInstruction = {
role: "user",
parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }],
};
} else if (role === "user" || (role === "system" && body.messages.length === 1)) {
const parts = convertOpenAIContentToParts(content);
if (parts.length > 0) {
result.contents.push({ role: "user", parts });
}
} else if (role === "assistant") {
const parts = [];
// Thinking/reasoning → thought part with signature
if (msg.reasoning_content) {
parts.push({
thought: true,
text: msg.reasoning_content,
});
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
text: "",
});
}
if (content) {
const text = typeof content === "string" ? content : extractTextContent(content);
if (text) {
parts.push({ text });
}
}
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
const toolCallIds = [];
for (const tc of msg.tool_calls) {
if (tc.type !== "function") continue;
const args = tryParseJSON(tc.function?.arguments || "{}");
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
functionCall: {
id: tc.id,
name: tc.function.name,
args: args,
},
});
toolCallIds.push(tc.id);
}
if (parts.length > 0) {
result.contents.push({ role: "model", parts });
}
// Check if there are actual tool responses in the next messages
const hasActualResponses = toolCallIds.some((fid) => toolResponses[fid]);
if (hasActualResponses) {
const toolParts = [];
for (const fid of toolCallIds) {
if (!toolResponses[fid]) continue;
let name = tcID2Name[fid];
if (!name) {
const idParts = fid.split("-");
if (idParts.length > 2) {
name = idParts.slice(0, -2).join("-");
} else {
name = fid;
}
}
let resp = toolResponses[fid];
let parsedResp = tryParseJSON(resp);
if (parsedResp === null) {
parsedResp = { result: resp };
} else if (typeof parsedResp !== "object") {
parsedResp = { result: parsedResp };
}
toolParts.push({
functionResponse: {
id: fid,
name: name,
response: { result: parsedResp },
},
});
}
if (toolParts.length > 0) {
result.contents.push({ role: "user", parts: toolParts });
}
}
} else if (parts.length > 0) {
result.contents.push({ role: "model", parts });
}
}
}
}
// Convert tools
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
const functionDeclarations = [];
for (const t of body.tools) {
// Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema)
if (t.name && t.input_schema) {
functionDeclarations.push({
name: t.name,
description: t.description || "",
parameters: t.input_schema || { type: "object", properties: {} },
});
}
// OpenAI format
else if (t.type === "function" && t.function) {
const fn = t.function;
functionDeclarations.push({
name: fn.name,
description: fn.description || "",
parameters: fn.parameters || { type: "object", properties: {} },
});
}
}
if (functionDeclarations.length > 0) {
result.tools = [{ functionDeclarations }];
}
}
return result;
}
// OpenAI -> Gemini (standard API)
export function openaiToGeminiRequest(model, body, stream) {
return openaiToGeminiBase(model, body, stream);
}
// OpenAI -> Gemini CLI (Cloud Code Assist)
export function openaiToGeminiCLIRequest(model, body, stream) {
const gemini = openaiToGeminiBase(model, body, stream);
const isClaude = model.toLowerCase().includes("claude");
// Add thinking config for CLI
if (body.reasoning_effort) {
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
const budget = budgetMap[body.reasoning_effort] || 8192;
gemini.generationConfig.thinkingConfig = {
thinkingBudget: budget,
include_thoughts: true,
};
}
// Thinking config from Claude format
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
gemini.generationConfig.thinkingConfig = {
thinkingBudget: body.thinking.budget_tokens,
include_thoughts: true,
};
}
// Clean schema for tools
if (gemini.tools?.[0]?.functionDeclarations) {
for (const fn of gemini.tools[0].functionDeclarations) {
if (fn.parameters) {
const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters);
fn.parameters = cleanedSchema;
// if (isClaude) {
// fn.parameters = cleanedSchema;
// } else {
// fn.parametersJsonSchema = cleanedSchema;
// delete fn.parameters;
// }
}
}
}
return gemini;
}
// Wrap Gemini CLI format in Cloud Code wrapper
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) {
const projectId = credentials?.projectId || generateProjectId();
const envelope = {
project: projectId,
model: model,
userAgent: isAntigravity ? "antigravity" : "gemini-cli",
requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(),
request: {
sessionId: generateSessionId(),
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
};
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
// Inject required default system prompt for Antigravity
const defaultPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(defaultPart);
} else {
envelope.request.systemInstruction = { role: "user", parts: [defaultPart] };
}
// Add toolConfig for Antigravity
if (geminiCLI.tools?.length > 0) {
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" },
};
}
} else {
// Keep safetySettings for Gemini CLI
envelope.request.safetySettings = geminiCLI.safetySettings;
}
return envelope;
}
// Wrap Claude format in Cloud Code envelope for Antigravity
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
const projectId = credentials?.projectId || generateProjectId();
const envelope = {
project: projectId,
model: model,
userAgent: "antigravity",
requestId: `agent-${generateUUID()}`,
requestType: "agent",
request: {
sessionId: generateSessionId(),
contents: [],
generationConfig: {
temperature: claudeRequest.temperature || 1,
maxOutputTokens: claudeRequest.max_tokens || 4096,
},
},
};
// Convert Claude messages to Gemini contents
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
for (const msg of claudeRequest.messages) {
const parts = [];
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "text") {
parts.push({ text: block.text });
} else if (block.type === "tool_use") {
parts.push({
functionCall: {
id: block.id,
name: block.name,
args: block.input || {},
},
});
} else if (block.type === "tool_result") {
let content = block.content;
if (Array.isArray(content)) {
content = content
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
.join("\n");
}
parts.push({
functionResponse: {
id: block.tool_use_id,
name: "unknown",
response: { result: tryParseJSON(content) || content },
},
});
}
}
} else if (typeof msg.content === "string") {
parts.push({ text: msg.content });
}
if (parts.length > 0) {
envelope.request.contents.push({
role: msg.role === "assistant" ? "model" : "user",
parts,
});
}
}
}
// Convert Claude tools to Gemini functionDeclarations
if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) {
const functionDeclarations = [];
for (const tool of claudeRequest.tools) {
if (tool.name && tool.input_schema) {
const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema);
functionDeclarations.push({
name: tool.name,
description: tool.description || "",
parameters: cleanedSchema,
});
}
}
if (functionDeclarations.length > 0) {
envelope.request.tools = [{ functionDeclarations }];
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" },
};
}
}
// Add system instruction (Antigravity default)
const defaultPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
const systemParts = [defaultPart];
if (claudeRequest.system) {
if (Array.isArray(claudeRequest.system)) {
for (const block of claudeRequest.system) {
if (block.text) systemParts.push({ text: block.text });
}
} else if (typeof claudeRequest.system === "string") {
systemParts.push({ text: claudeRequest.system });
}
}
envelope.request.systemInstruction = { role: "user", parts: systemParts };
return envelope;
}
// OpenAI -> Antigravity (Sandbox Cloud Code with wrapper)
export function openaiToAntigravityRequest(model, body, stream, credentials = null) {
const isClaude = model.toLowerCase().includes("claude");
if (isClaude) {
const claudeRequest = openaiToClaudeRequestForAntigravity(model, body, stream);
return wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials);
}
const geminiCLI = openaiToGeminiCLIRequest(model, body, stream);
return wrapInCloudCodeEnvelope(model, geminiCLI, credentials, true);
}
// Register
register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null);
register(
FORMATS.OPENAI,
FORMATS.GEMINI_CLI,
(model, body, stream, credentials) =>
wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials),
null
);
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null);

View File

@@ -0,0 +1,290 @@
/**
* OpenAI to Kiro Request Translator
* Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
/**
* Convert OpenAI messages to Kiro format
* Rules: system/tool/user -> user role, merge consecutive same roles
*/
function convertMessages(messages, tools, model) {
let history = [];
let currentMessage = null;
let pendingUserContent = [];
let pendingAssistantContent = [];
let pendingToolResults = [];
let currentRole = null;
const flushPending = () => {
if (currentRole === "user") {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const userMsg = {
userInputMessage: {
content: content,
modelId: "",
},
};
if (pendingToolResults.length > 0) {
userMsg.userInputMessage.userInputMessageContext = {
toolResults: pendingToolResults,
};
}
// Add tools to first user message
if (tools && tools.length > 0 && history.length === 0) {
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}`;
}
return {
toolSpecification: {
name,
description,
inputSchema: {
json: t.function?.parameters || t.parameters || t.input_schema || {},
},
},
};
});
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
pendingToolResults = [];
} else if (currentRole === "assistant") {
const content = pendingAssistantContent.join("\n\n").trim() || "...";
const assistantMsg = {
assistantResponseMessage: {
content: content,
},
};
history.push(assistantMsg);
pendingAssistantContent = [];
}
};
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 = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
const textParts = msg.content
.filter((c) => c.type === "text" || c.text)
.map((c) => c.text || "");
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)
? block.content.map((c) => c.text || "").join("\n")
: typeof block.content === "string"
? block.content
: "";
pendingToolResults.push({
toolUseId: block.tool_use_id,
status: "success",
content: [{ text: text }],
});
});
}
}
// Handle tool role (from normalized)
if (msg.role === "tool") {
const toolContent = typeof msg.content === "string" ? msg.content : "";
pendingToolResults.push({
toolUseId: msg.tool_call_id,
status: "success",
content: [{ text: toolContent }],
});
} else if (content) {
pendingUserContent.push(content);
}
} else if (role === "assistant") {
// 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) => {
if (tc.function) {
return {
toolUseId: tc.id || uuidv4(),
name: tc.function.name,
input:
typeof tc.function.arguments === "string"
? JSON.parse(tc.function.arguments)
: tc.function.arguments || {},
};
} else {
return {
toolUseId: tc.id || uuidv4(),
name: tc.name,
input: tc.input || {},
};
}
});
}
currentRole = null;
}
}
}
// Flush remaining
if (currentRole !== null) {
flushPending();
}
// If last message in history is userInputMessage, use it as currentMessage
if (history.length > 0 && history[history.length - 1].userInputMessage) {
currentMessage = history.pop();
}
const firstHistoryItem = history[0];
if (
firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools &&
!currentMessage?.userInputMessage?.userInputMessageContext?.tools
) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryItem.userInputMessage.userInputMessageContext.tools;
}
// Clean up history for Kiro API compatibility
history.forEach((item) => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if (
item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0
) {
delete item.userInputMessage.userInputMessageContext;
}
if (item.userInputMessage && !item.userInputMessage.modelId) {
item.userInputMessage.modelId = model;
}
});
return { history, currentMessage };
}
/**
* Build Kiro payload from OpenAI format
*/
export function buildKiroPayload(model, body, stream, credentials) {
const messages = body.messages || [];
const tools = body.tools || [];
const maxTokens = 32000;
const temperature = body.temperature;
const topP = body.top_p;
const { history, currentMessage } = convertMessages(messages, tools, model);
const profileArn = credentials?.providerSpecificData?.profileArn || "";
let finalContent = currentMessage?.userInputMessage?.content || "";
const timestamp = new Date().toISOString();
finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`;
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: model,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext,
}),
},
},
history: history,
},
};
if (profileArn) {
payload.profileArn = profileArn;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens;
if (temperature !== undefined) payload.inferenceConfig.temperature = temperature;
if (topP !== undefined) payload.inferenceConfig.topP = topP;
}
return payload;
}
register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null);

View File

@@ -0,0 +1,242 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Create OpenAI chunk helper
function createChunk(state, delta, finishReason = null) {
return {
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta,
finish_reason: finishReason,
},
],
};
}
// Convert Claude stream chunk to OpenAI format
export function claudeToOpenAIResponse(chunk, state) {
if (!chunk) return null;
const results = [];
const event = chunk.type;
switch (event) {
case "message_start": {
state.messageId = chunk.message?.id || `msg_${Date.now()}`;
state.model = chunk.message?.model;
state.toolCallIndex = 0;
results.push(createChunk(state, { role: "assistant" }));
break;
}
case "content_block_start": {
const block = chunk.content_block;
if (block?.type === "text") {
state.textBlockStarted = true;
} else if (block?.type === "thinking") {
state.inThinkingBlock = true;
state.currentBlockIndex = chunk.index;
results.push(createChunk(state, { content: "<think>" }));
} else if (block?.type === "tool_use") {
const toolCallIndex = state.toolCallIndex++;
// Restore original tool name from mapping (Claude OAuth)
const toolName = state.toolNameMap?.get(block.name) || block.name;
const toolCall = {
index: toolCallIndex,
id: block.id,
type: "function",
function: {
name: toolName,
arguments: "",
},
};
state.toolCalls.set(chunk.index, toolCall);
results.push(createChunk(state, { tool_calls: [toolCall] }));
}
break;
}
case "content_block_delta": {
const delta = chunk.delta;
if (delta?.type === "text_delta" && delta.text) {
results.push(createChunk(state, { content: delta.text }));
} else if (delta?.type === "thinking_delta" && delta.thinking) {
results.push(createChunk(state, { content: delta.thinking }));
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
const toolCall = state.toolCalls.get(chunk.index);
if (toolCall) {
toolCall.function.arguments += delta.partial_json;
results.push(
createChunk(state, {
tool_calls: [
{
index: toolCall.index,
id: toolCall.id,
function: { arguments: delta.partial_json },
},
],
})
);
}
}
break;
}
case "content_block_stop": {
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
results.push(createChunk(state, { content: "</think>" }));
state.inThinkingBlock = false;
}
state.textBlockStarted = false;
state.thinkingBlockStarted = false;
break;
}
case "message_delta": {
// Extract usage from message_delta event (Claude native format)
// Normalize to OpenAI format (prompt_tokens/completion_tokens) for consistent logging
if (chunk.usage && typeof chunk.usage === "object") {
const inputTokens =
typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : 0;
const outputTokens =
typeof chunk.usage.output_tokens === "number" ? chunk.usage.output_tokens : 0;
const cacheReadTokens =
typeof chunk.usage.cache_read_input_tokens === "number"
? chunk.usage.cache_read_input_tokens
: 0;
const cacheCreationTokens =
typeof chunk.usage.cache_creation_input_tokens === "number"
? chunk.usage.cache_creation_input_tokens
: 0;
// Use OpenAI format keys for consistent logging in stream.js
state.usage = {
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// Store cache tokens if present
if (cacheReadTokens > 0) {
state.usage.cache_read_input_tokens = cacheReadTokens;
}
if (cacheCreationTokens > 0) {
state.usage.cache_creation_input_tokens = cacheCreationTokens;
}
}
if (chunk.delta?.stop_reason) {
state.finishReason = convertStopReason(chunk.delta.stop_reason);
const finalChunk = {
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: {},
finish_reason: state.finishReason,
},
],
};
// Include usage in final chunk if available
if (state.usage && typeof state.usage === "object") {
const inputTokens = state.usage.input_tokens || 0;
const outputTokens = state.usage.output_tokens || 0;
const cachedTokens = state.usage.cache_read_input_tokens || 0;
const cacheCreationTokens = state.usage.cache_creation_input_tokens || 0;
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
// completion_tokens = output_tokens
// total_tokens = prompt_tokens + completion_tokens
const promptTokens = inputTokens + cachedTokens + cacheCreationTokens;
const completionTokens = outputTokens;
const totalTokens = promptTokens + completionTokens;
finalChunk.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: totalTokens,
};
// Add prompt_tokens_details if cached tokens exist
if (cachedTokens > 0 || cacheCreationTokens > 0) {
finalChunk.usage.prompt_tokens_details = {};
if (cachedTokens > 0) {
finalChunk.usage.prompt_tokens_details.cached_tokens = cachedTokens;
}
if (cacheCreationTokens > 0) {
finalChunk.usage.prompt_tokens_details.cache_creation_tokens = cacheCreationTokens;
}
}
}
results.push(finalChunk);
state.finishReasonSent = true;
}
break;
}
case "message_stop": {
if (!state.finishReasonSent) {
const finishReason =
state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop");
const usageObj =
state.usage && typeof state.usage === "object"
? {
usage: {
prompt_tokens: state.usage.input_tokens || 0,
completion_tokens: state.usage.output_tokens || 0,
total_tokens: (state.usage.input_tokens || 0) + (state.usage.output_tokens || 0),
},
}
: {};
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: {},
finish_reason: finishReason,
},
],
...usageObj,
});
state.finishReasonSent = true;
}
break;
}
}
return results.length > 0 ? results : null;
}
// Convert Claude stop_reason to OpenAI finish_reason
function convertStopReason(reason) {
switch (reason) {
case "end_turn":
return "stop";
case "max_tokens":
return "length";
case "tool_use":
return "tool_calls";
case "stop_sequence":
return "stop";
default:
return "stop";
}
}
// Register
register(FORMATS.CLAUDE, FORMATS.OPENAI, null, claudeToOpenAIResponse);

View File

@@ -0,0 +1,30 @@
/**
* Cursor to OpenAI Response Translator
* CursorExecutor already emits OpenAI format - this is a passthrough
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Convert Cursor response to OpenAI format
* Since CursorExecutor.transformProtobufToSSE/JSON already emits OpenAI chunks,
* this is a passthrough translator (similar to Kiro pattern)
*/
export function convertCursorToOpenAI(chunk, state) {
if (!chunk) return null;
// If chunk is already in OpenAI format (from executor transform), return as-is
if (chunk.object === "chat.completion.chunk" && chunk.choices) {
return chunk;
}
// If chunk is a completion object (non-streaming), return as-is
if (chunk.object === "chat.completion" && chunk.choices) {
return chunk;
}
// Fallback: return chunk as-is (should not reach here)
return chunk;
}
register(FORMATS.CURSOR, FORMATS.OPENAI, null, convertCursorToOpenAI);

View File

@@ -0,0 +1,182 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Direct Gemini → Claude response translator.
* Converts Gemini streaming chunks directly to Claude Messages API
* streaming events, skipping the OpenAI hub intermediate step.
*/
export function geminiToClaudeResponse(chunk, state) {
if (!chunk) return null;
// Handle Antigravity wrapper
const response = chunk.response || chunk;
if (!response || !response.candidates?.[0]) return null;
const results = [];
const candidate = response.candidates[0];
const content = candidate.content;
// ── Initialize: emit message_start ─────────────────────────────
if (!state.messageId) {
state.messageId = response.responseId || `msg_${Date.now()}`;
state.model = response.modelVersion || "gemini";
state.contentBlockIndex = 0;
results.push({
type: "message_start",
message: {
id: state.messageId,
type: "message",
role: "assistant",
model: state.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
});
}
// ── Process parts ──────────────────────────────────────────────
if (content?.parts) {
for (const part of content.parts) {
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const isThought = part.thought === true;
// Thinking content → thinking block
if (isThought && part.text) {
const idx = state.contentBlockIndex++;
results.push({
type: "content_block_start",
index: idx,
content_block: { type: "thinking", thinking: "" },
});
results.push({
type: "content_block_delta",
index: idx,
delta: { type: "thinking_delta", thinking: part.text },
});
results.push({ type: "content_block_stop", index: idx });
continue;
}
// Function call → tool_use block (with or without thoughtSignature)
if (part.functionCall) {
const fc = part.functionCall;
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
results.push({
type: "content_block_start",
index: idx,
content_block: {
type: "tool_use",
id: toolId,
name: fc.name,
input: {},
},
});
// Send args as a single JSON delta
const argsStr = JSON.stringify(fc.args || {});
results.push({
type: "content_block_delta",
index: idx,
delta: { type: "input_json_delta", partial_json: argsStr },
});
results.push({ type: "content_block_stop", index: idx });
if (!state.hasToolUse) state.hasToolUse = true;
continue;
}
// Text content → text block
if (part.text !== undefined && part.text !== "" && !hasThoughtSig) {
const idx = state.contentBlockIndex++;
results.push({
type: "content_block_start",
index: idx,
content_block: { type: "text", text: "" },
});
results.push({
type: "content_block_delta",
index: idx,
delta: { type: "text_delta", text: part.text },
});
results.push({ type: "content_block_stop", index: idx });
}
// Text with thoughtSignature but not a thought (model output after thinking)
if (
hasThoughtSig &&
part.text !== undefined &&
part.text !== "" &&
!isThought &&
!part.functionCall
) {
const idx = state.contentBlockIndex++;
results.push({
type: "content_block_start",
index: idx,
content_block: { type: "text", text: "" },
});
results.push({
type: "content_block_delta",
index: idx,
delta: { type: "text_delta", text: part.text },
});
results.push({ type: "content_block_stop", index: idx });
}
}
}
// ── Usage metadata ─────────────────────────────────────────────
const usageMeta = response.usageMetadata || chunk.usageMetadata;
if (usageMeta && typeof usageMeta === "object") {
const inputTokens =
typeof usageMeta.promptTokenCount === "number" ? usageMeta.promptTokenCount : 0;
const candidatesTokens =
typeof usageMeta.candidatesTokenCount === "number" ? usageMeta.candidatesTokenCount : 0;
const thoughtsTokens =
typeof usageMeta.thoughtsTokenCount === "number" ? usageMeta.thoughtsTokenCount : 0;
const cachedTokens =
typeof usageMeta.cachedContentTokenCount === "number" ? usageMeta.cachedContentTokenCount : 0;
state.usage = {
input_tokens: inputTokens,
output_tokens: candidatesTokens + thoughtsTokens,
};
if (cachedTokens > 0) {
state.usage.cache_read_input_tokens = cachedTokens;
}
}
// ── Finish reason → message_delta + message_stop ───────────────
if (candidate.finishReason) {
let stopReason;
const reason = candidate.finishReason.toLowerCase();
if (state.hasToolUse || reason === "tool_calls") {
stopReason = "tool_use";
} else if (reason === "max_tokens" || reason === "length") {
stopReason = "max_tokens";
} else {
stopReason = "end_turn";
}
results.push({
type: "message_delta",
delta: { stop_reason: stopReason, stop_sequence: null },
usage: state.usage || { input_tokens: 0, output_tokens: 0 },
});
results.push({ type: "message_stop" });
}
return results.length > 0 ? results : null;
}
// Register as direct path: Gemini → Claude
register(FORMATS.GEMINI, FORMATS.CLAUDE, null, geminiToClaudeResponse);
register(FORMATS.GEMINI_CLI, FORMATS.CLAUDE, null, geminiToClaudeResponse);
register(FORMATS.ANTIGRAVITY, FORMATS.CLAUDE, null, geminiToClaudeResponse);

View File

@@ -0,0 +1,258 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Convert Gemini response chunk to OpenAI format
export function geminiToOpenAIResponse(chunk, state) {
if (!chunk) return null;
// Handle Antigravity wrapper
const response = chunk.response || chunk;
if (!response || !response.candidates?.[0]) return null;
const results = [];
const candidate = response.candidates[0];
const content = candidate.content;
// Initialize state
if (!state.messageId) {
state.messageId = response.responseId || `msg_${Date.now()}`;
state.model = response.modelVersion || "gemini";
state.functionIndex = 0;
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: { role: "assistant" },
finish_reason: null,
},
],
});
}
// Process parts
if (content?.parts) {
for (const part of content.parts) {
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
const isThought = part.thought === true;
// Handle thought signature (thinking mode)
if (hasThoughtSig) {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasFunctionCall = !!part.functionCall;
if (hasTextContent) {
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: isThought ? { reasoning_content: part.text } : { content: part.text },
finish_reason: null,
},
],
});
}
if (hasFunctionCall) {
const fcName = part.functionCall.name;
const fcArgs = part.functionCall.args || {};
const toolCallIndex = state.functionIndex++;
const toolCall = {
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
index: toolCallIndex,
type: "function",
function: {
name: fcName,
arguments: JSON.stringify(fcArgs),
},
};
state.toolCalls.set(toolCallIndex, toolCall);
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: { tool_calls: [toolCall] },
finish_reason: null,
},
],
});
}
continue;
}
// Text content (non-thinking)
if (part.text !== undefined && part.text !== "") {
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: { content: part.text },
finish_reason: null,
},
],
});
}
// Function call
if (part.functionCall) {
const fcName = part.functionCall.name;
const fcArgs = part.functionCall.args || {};
const toolCallIndex = state.functionIndex++;
const toolCall = {
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
index: toolCallIndex,
type: "function",
function: {
name: fcName,
arguments: JSON.stringify(fcArgs),
},
};
state.toolCalls.set(toolCallIndex, toolCall);
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: { tool_calls: [toolCall] },
finish_reason: null,
},
],
});
}
// Inline data (images)
const inlineData = part.inlineData || part.inline_data;
if (inlineData?.data) {
const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png";
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: {
images: [
{
type: "image_url",
image_url: { url: `data:${mimeType};base64,${inlineData.data}` },
},
],
},
finish_reason: null,
},
],
});
}
}
}
// Usage metadata - extract before finish reason so we can include it
const usageMeta = response.usageMetadata || chunk.usageMetadata;
if (usageMeta && typeof usageMeta === "object") {
const cachedTokens =
typeof usageMeta.cachedContentTokenCount === "number" ? usageMeta.cachedContentTokenCount : 0;
const promptTokenCountRaw =
typeof usageMeta.promptTokenCount === "number" ? usageMeta.promptTokenCount : 0;
const thoughtsTokens =
typeof usageMeta.thoughtsTokenCount === "number" ? usageMeta.thoughtsTokenCount : 0;
let candidatesTokens =
typeof usageMeta.candidatesTokenCount === "number" ? usageMeta.candidatesTokenCount : 0;
const totalTokens =
typeof usageMeta.totalTokenCount === "number" ? usageMeta.totalTokenCount : 0;
// prompt_tokens = promptTokenCount (includes cached tokens, matching claude-to-openai.js behavior)
const promptTokens = promptTokenCountRaw;
// Fallback calculation if candidatesTokenCount is 0 but totalTokenCount exists
if (candidatesTokens === 0 && totalTokens > 0) {
candidatesTokens = totalTokens - promptTokenCountRaw - thoughtsTokens;
if (candidatesTokens < 0) candidatesTokens = 0;
}
// completion_tokens = candidatesTokenCount + thoughtsTokenCount (match Go code)
const completionTokens = candidatesTokens + thoughtsTokens;
state.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: totalTokens,
};
// Add prompt_tokens_details if cached tokens exist
if (cachedTokens > 0) {
state.usage.prompt_tokens_details = {
cached_tokens: cachedTokens,
};
}
// Add completion_tokens_details if reasoning tokens exist
if (thoughtsTokens > 0) {
state.usage.completion_tokens_details = {
reasoning_tokens: thoughtsTokens,
};
}
}
// Finish reason - include usage in final chunk
if (candidate.finishReason) {
let finishReason = candidate.finishReason.toLowerCase();
if (finishReason === "stop" && state.toolCalls.size > 0) {
finishReason = "tool_calls";
}
const finalChunk = {
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: state.model,
choices: [
{
index: 0,
delta: {},
finish_reason: finishReason,
},
],
};
// Include usage in final chunk for downstream translators
if (state.usage) {
finalChunk.usage = state.usage;
}
results.push(finalChunk);
state.finishReason = finishReason;
}
return results.length > 0 ? results : null;
}
// Register
register(FORMATS.GEMINI, FORMATS.OPENAI, null, geminiToOpenAIResponse);
register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, null, geminiToOpenAIResponse);
register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, null, geminiToOpenAIResponse);

View File

@@ -0,0 +1,198 @@
/**
* Kiro to OpenAI Response Translator
* Converts Kiro/AWS CodeWhisperer streaming events to OpenAI SSE format
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Parse Kiro SSE event and convert to OpenAI format
* Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc.
*/
export function convertKiroToOpenAI(chunk, state) {
if (!chunk) return null;
// If chunk is already in OpenAI format (from executor transform), return as-is
if (chunk.object === "chat.completion.chunk" && chunk.choices) {
return chunk;
}
// Handle string chunk (raw SSE data)
let data = chunk;
if (typeof chunk === "string") {
// Parse SSE format: event:xxx\ndata:xxx
const lines = chunk.split("\n");
let eventType = "";
let eventData = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith(":event-type:")) {
eventType = line.slice(12).trim();
} else if (line.startsWith("data:")) {
eventData = line.slice(5).trim();
} else if (line.startsWith(":content-type:")) {
// Skip content-type header
} else if (line.trim() && !line.startsWith(":")) {
// Raw JSON data
eventData = line.trim();
}
}
if (!eventData) return null;
try {
data = JSON.parse(eventData);
data._eventType = eventType;
} catch {
// Not JSON, might be raw text
data = { text: eventData, _eventType: eventType };
}
}
// Initialize state if needed
if (!state.responseId) {
state.responseId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.chunkIndex = 0;
}
const eventType = data._eventType || data.event || "";
// Handle different Kiro event types
if (eventType === "assistantResponseEvent" || data.assistantResponseEvent) {
const content = data.assistantResponseEvent?.content || data.content || "";
if (!content) return null;
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [
{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
content: content,
},
finish_reason: null,
},
],
};
state.chunkIndex++;
return openaiChunk;
}
// Handle reasoning/thinking events
if (eventType === "reasoningContentEvent" || data.reasoningContentEvent) {
const content = data.reasoningContentEvent?.content || data.content || "";
if (!content) return null;
// Convert to thinking block format (Claude-style)
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [
{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
content: `<thinking>${content}</thinking>`,
},
finish_reason: null,
},
],
};
state.chunkIndex++;
return openaiChunk;
}
// Handle tool use events
if (eventType === "toolUseEvent" || data.toolUseEvent) {
const toolUse = data.toolUseEvent || data;
const toolCallId = toolUse.toolUseId || `call_${Date.now()}`;
const toolName = toolUse.name || "";
const toolInput = toolUse.input || {};
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [
{
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
tool_calls: [
{
index: 0,
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(toolInput),
},
},
],
},
finish_reason: null,
},
],
};
state.chunkIndex++;
return openaiChunk;
}
// Handle completion/done events
if (eventType === "messageStopEvent" || eventType === "done" || data.messageStopEvent) {
state.finishReason = "stop"; // Mark for usage injection in stream.js
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "kiro",
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
};
// Include usage in final chunk if available
if (state.usage && typeof state.usage === "object") {
openaiChunk.usage = state.usage;
}
return openaiChunk;
}
// Handle usage events
if (eventType === "usageEvent" || data.usageEvent) {
const usage = data.usageEvent || data;
if (usage && typeof usage === "object") {
state.usage = {
prompt_tokens: usage.inputTokens || 0,
completion_tokens: usage.outputTokens || 0,
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0),
};
}
return null;
}
// Unknown event type - skip
return null;
}
// Register translator
register(FORMATS.KIRO, FORMATS.OPENAI, null, convertKiroToOpenAI);

View File

@@ -0,0 +1,558 @@
/**
* Translator: OpenAI Chat Completions → OpenAI Responses API (response)
* Converts streaming chunks from Chat Completions to Responses API events
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
/**
* Translate OpenAI chunk to Responses API events
* @returns {Array} Array of events with { event, data } structure
*/
export function openaiToOpenAIResponsesResponse(chunk, state) {
if (!chunk) {
return flushEvents(state);
}
if (!chunk.choices?.length) return [];
const events = [];
const nextSeq = () => ++state.seq;
const emit = (eventType, data) => {
data.sequence_number = nextSeq();
events.push({ event: eventType, data });
};
const choice = chunk.choices[0];
const idx = choice.index || 0;
const delta = choice.delta || {};
// Emit initial events
if (!state.started) {
state.started = true;
state.responseId = chunk.id ? `resp_${chunk.id}` : state.responseId;
emit("response.created", {
type: "response.created",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "in_progress",
background: false,
error: null,
output: [],
},
});
emit("response.in_progress", {
type: "response.in_progress",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "in_progress",
},
});
}
// Handle reasoning_content
if (delta.reasoning_content) {
startReasoning(state, emit, idx);
emitReasoningDelta(state, emit, delta.reasoning_content);
}
// Handle text content
if (delta.content) {
let content = delta.content;
if (content.includes("<think>")) {
state.inThinking = true;
content = content.replace("<think>", "");
startReasoning(state, emit, idx);
}
if (content.includes("</think>")) {
const parts = content.split("</think>");
const thinkPart = parts[0];
const textPart = parts.slice(1).join("</think>");
if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
closeReasoning(state, emit);
state.inThinking = false;
content = textPart;
}
if (state.inThinking && content) {
emitReasoningDelta(state, emit, content);
return events;
}
if (content) {
emitTextContent(state, emit, idx, content);
}
}
// Handle tool_calls
if (delta.tool_calls) {
closeMessage(state, emit, idx);
for (const tc of delta.tool_calls) {
emitToolCall(state, emit, tc);
}
}
// Handle finish_reason
if (choice.finish_reason) {
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
closeReasoning(state, emit);
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
sendCompleted(state, emit);
}
return events;
}
// Helper functions
function startReasoning(state, emit, idx) {
if (!state.reasoningId) {
state.reasoningId = `rs_${state.responseId}_${idx}`;
state.reasoningIndex = idx;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
item: { id: state.reasoningId, type: "reasoning", summary: [] },
});
emit("response.reasoning_summary_part.added", {
type: "response.reasoning_summary_part.added",
item_id: state.reasoningId,
output_index: idx,
summary_index: 0,
part: { type: "summary_text", text: "" },
});
state.reasoningPartAdded = true;
}
}
function emitReasoningDelta(state, emit, text) {
if (!text) return;
state.reasoningBuf += text;
emit("response.reasoning_summary_text.delta", {
type: "response.reasoning_summary_text.delta",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
delta: text,
});
}
function closeReasoning(state, emit) {
if (state.reasoningId && !state.reasoningDone) {
state.reasoningDone = true;
emit("response.reasoning_summary_text.done", {
type: "response.reasoning_summary_text.done",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
text: state.reasoningBuf,
});
emit("response.reasoning_summary_part.done", {
type: "response.reasoning_summary_part.done",
item_id: state.reasoningId,
output_index: state.reasoningIndex,
summary_index: 0,
part: { type: "summary_text", text: state.reasoningBuf },
});
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: state.reasoningIndex,
item: {
id: state.reasoningId,
type: "reasoning",
summary: [{ type: "summary_text", text: state.reasoningBuf }],
},
});
}
}
function emitTextContent(state, emit, idx, content) {
if (!state.msgItemAdded[idx]) {
state.msgItemAdded[idx] = true;
const msgId = `msg_${state.responseId}_${idx}`;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
item: { id: msgId, type: "message", content: [], role: "assistant" },
});
}
if (!state.msgContentAdded[idx]) {
state.msgContentAdded[idx] = true;
emit("response.content_part.added", {
type: "response.content_part.added",
item_id: `msg_${state.responseId}_${idx}`,
output_index: idx,
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: "" },
});
}
emit("response.output_text.delta", {
type: "response.output_text.delta",
item_id: `msg_${state.responseId}_${idx}`,
output_index: idx,
content_index: 0,
delta: content,
logprobs: [],
});
if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = "";
state.msgTextBuf[idx] += content;
}
function closeMessage(state, emit, idx) {
if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) {
state.msgItemDone[idx] = true;
const fullText = state.msgTextBuf[idx] || "";
const msgId = `msg_${state.responseId}_${idx}`;
emit("response.output_text.done", {
type: "response.output_text.done",
item_id: msgId,
output_index: parseInt(idx),
content_index: 0,
text: fullText,
logprobs: [],
});
emit("response.content_part.done", {
type: "response.content_part.done",
item_id: msgId,
output_index: parseInt(idx),
content_index: 0,
part: { type: "output_text", annotations: [], logprobs: [], text: fullText },
});
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: msgId,
type: "message",
content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
role: "assistant",
},
});
}
}
function emitToolCall(state, emit, tc) {
const tcIdx = tc.index ?? 0;
const newCallId = tc.id;
const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName;
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: "function_call",
arguments: "",
call_id: newCallId,
name: state.funcNames[tcIdx] || "",
},
});
}
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
output_index: tcIdx,
delta: tc.function.arguments,
});
}
state.funcArgsBuf[tcIdx] += tc.function.arguments;
}
}
function closeToolCall(state, emit, idx) {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}";
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args,
});
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: `fc_${callId}`,
type: "function_call",
arguments: args,
call_id: callId,
name: state.funcNames[idx] || "",
},
});
state.funcItemDone[idx] = true;
state.funcArgsDone[idx] = true;
}
}
function sendCompleted(state, emit) {
if (!state.completedSent) {
state.completedSent = true;
emit("response.completed", {
type: "response.completed",
response: {
id: state.responseId,
object: "response",
created_at: state.created,
status: "completed",
background: false,
error: null,
},
});
}
}
function flushEvents(state) {
if (state.completedSent) return [];
const events = [];
const nextSeq = () => ++state.seq;
const emit = (eventType, data) => {
data.sequence_number = nextSeq();
events.push({ event: eventType, data });
};
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
closeReasoning(state, emit);
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
sendCompleted(state, emit);
return events;
}
/**
* Translate OpenAI Responses API chunk to OpenAI Chat Completions format
* This is for when Codex returns data and we need to send it to an OpenAI-compatible client
*/
export function openaiResponsesToOpenAIResponse(chunk, state) {
if (!chunk) {
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
state.finishReasonSent = true;
return {
id: state.chatId || `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: state.created || Math.floor(Date.now() / 1000),
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
};
}
return null;
}
// Handle different event types from Responses API
const eventType = chunk.type || chunk.event;
const data = chunk.data || chunk;
// Initialize state
if (!state.started) {
state.started = true;
state.chatId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.toolCallIndex = 0;
state.currentToolCallId = null;
}
// Text content delta
if (eventType === "response.output_text.delta") {
const delta = data.delta || "";
if (!delta) return null;
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: { content: delta },
finish_reason: null,
},
],
};
}
// Text content done (ignore, we handle via delta)
if (eventType === "response.output_text.done") {
return null;
}
// Function call started
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
const item = data.item;
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: state.toolCallIndex,
id: state.currentToolCallId,
type: "function",
function: {
name: item.name || "",
arguments: "",
},
},
],
},
finish_reason: null,
},
],
};
}
// Function call arguments delta
if (eventType === "response.function_call_arguments.delta") {
const argsDelta = data.delta || "";
if (!argsDelta) return null;
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: state.toolCallIndex,
function: { arguments: argsDelta },
},
],
},
finish_reason: null,
},
],
};
}
// Function call done
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
state.toolCallIndex++;
return null;
}
// Response completed
if (eventType === "response.completed") {
// Extract usage from response.completed event
const responseUsage = data.response?.usage;
if (responseUsage && typeof responseUsage === "object") {
const inputTokens = responseUsage.input_tokens || responseUsage.prompt_tokens || 0;
const outputTokens = responseUsage.output_tokens || responseUsage.completion_tokens || 0;
const cacheReadTokens = responseUsage.cache_read_input_tokens || 0;
const cacheCreationTokens = responseUsage.cache_creation_input_tokens || 0;
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
state.usage = {
prompt_tokens: promptTokens,
completion_tokens: outputTokens,
total_tokens: promptTokens + outputTokens,
};
// Add prompt_tokens_details if cache tokens exist
if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
state.usage.prompt_tokens_details = {};
if (cacheReadTokens > 0) {
state.usage.prompt_tokens_details.cached_tokens = cacheReadTokens;
}
if (cacheCreationTokens > 0) {
state.usage.prompt_tokens_details.cache_creation_tokens = cacheCreationTokens;
}
}
}
if (!state.finishReasonSent) {
state.finishReasonSent = true;
state.finishReason = "stop"; // Mark for usage injection in stream.js
const finalChunk = {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
};
// Include usage in final chunk if available
if (state.usage && typeof state.usage === "object") {
finalChunk.usage = state.usage;
}
return finalChunk;
}
return null;
}
// Reasoning events (convert to content or skip)
if (eventType === "response.reasoning_summary_text.delta") {
// Optionally include reasoning as content, or skip
return null;
}
// Ignore other events
return null;
}
// Register both directions
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, openaiToOpenAIResponsesResponse);
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, null, openaiResponsesToOpenAIResponse);

View File

@@ -0,0 +1,124 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Convert OpenAI SSE chunk to Antigravity SSE format
// Real Antigravity format:
// data: {"response":{"candidates":[{"content":{"role":"model","parts":[...]}, "finishReason":"STOP"}], "usageMetadata":{...}, "modelVersion":"...", "responseId":"..."}}
// Tool calls: OpenAI sends incremental args across chunks → accumulate and emit ONCE at finish
export function openaiToAntigravityResponse(chunk, state) {
if (!chunk) return null;
const choice = chunk.choices?.[0];
if (!choice) {
if (chunk.usage) {
state._usage = chunk.usage;
}
return null;
}
const delta = choice.delta || {};
const finishReason = choice.finish_reason;
// Init state
if (!state._toolCallAccum) state._toolCallAccum = {};
if (!state._responseId) state._responseId = chunk.id || `resp_${Date.now()}`;
if (!state._modelVersion) state._modelVersion = chunk.model || "";
const parts = [];
// Thinking/reasoning → thought part
if (delta.reasoning_content) {
parts.push({ thought: true, text: delta.reasoning_content });
}
// Text content
if (delta.content) {
parts.push({ text: delta.content });
}
// Accumulate tool calls silently (no emit until finish)
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!state._toolCallAccum[idx]) {
state._toolCallAccum[idx] = { id: "", name: "", arguments: "" };
}
const accum = state._toolCallAccum[idx];
if (tc.id) accum.id = tc.id;
if (tc.function?.name) accum.name += tc.function.name;
if (tc.function?.arguments) accum.arguments += tc.function.arguments;
}
// Skip emit — wait for finish_reason
if (parts.length === 0 && !finishReason) return null;
}
// On finish, emit accumulated tool calls as complete functionCall parts
if (finishReason) {
const indices = Object.keys(state._toolCallAccum);
for (const idx of indices) {
const accum = state._toolCallAccum[idx];
let args = {};
try {
args = JSON.parse(accum.arguments);
} catch {
/* empty */
}
parts.push({
functionCall: {
name: accum.name,
args,
},
});
}
}
// Skip empty non-finish chunks
if (parts.length === 0 && !finishReason) return null;
// Ensure at least empty text part on finish with no content
if (parts.length === 0 && finishReason) {
parts.push({ text: "" });
}
// Build candidate
const candidate = { content: { role: "model", parts } };
// Finish reason mapping
if (finishReason) {
const reasonMap = {
stop: "STOP",
length: "MAX_TOKENS",
tool_calls: "STOP",
content_filter: "SAFETY",
};
candidate.finishReason = reasonMap[finishReason] || "STOP";
}
// Build response
const response = {
candidates: [candidate],
modelVersion: state._modelVersion,
responseId: state._responseId,
};
// Usage metadata
const usage = chunk.usage || state._usage;
if (usage) {
response.usageMetadata = {
promptTokenCount: usage.prompt_tokens || 0,
candidatesTokenCount: usage.completion_tokens || 0,
totalTokenCount: usage.total_tokens || 0,
};
if (usage.completion_tokens_details?.reasoning_tokens) {
response.usageMetadata.thoughtsTokenCount = usage.completion_tokens_details.reasoning_tokens;
}
if (usage.prompt_tokens_details?.cached_tokens) {
response.usageMetadata.cachedContentTokenCount = usage.prompt_tokens_details.cached_tokens;
}
}
return { response };
}
// Register
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, null, openaiToAntigravityResponse);

View File

@@ -0,0 +1,231 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
// Prefix for Claude OAuth tool names (must match request translator)
const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_";
// Helper: stop thinking block if started
function stopThinkingBlock(state, results) {
if (!state.thinkingBlockStarted) return;
results.push({
type: "content_block_stop",
index: state.thinkingBlockIndex,
});
state.thinkingBlockStarted = false;
}
// Helper: stop text block if started
function stopTextBlock(state, results) {
if (!state.textBlockStarted || state.textBlockClosed) return;
state.textBlockClosed = true;
results.push({
type: "content_block_stop",
index: state.textBlockIndex,
});
state.textBlockStarted = false;
}
// Convert OpenAI stream chunk to Claude format
export function openaiToClaudeResponse(chunk, state) {
if (!chunk || !chunk.choices?.[0]) return null;
const results = [];
const choice = chunk.choices[0];
const delta = choice.delta;
// Track usage from OpenAI chunk if available
if (chunk.usage && typeof chunk.usage === "object") {
const promptTokens =
typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
const outputTokens =
typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
// Extract cache tokens from prompt_tokens_details
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
// Because OpenAI's prompt_tokens includes all prompt-side tokens
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
state.usage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// Add cache_read_input_tokens if present
if (cacheReadTokens > 0) {
state.usage.cache_read_input_tokens = cacheReadTokens;
}
// Add cache_creation_input_tokens if present
if (cacheCreateTokens > 0) {
state.usage.cache_creation_input_tokens = cacheCreateTokens;
}
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
// No need to add separately as Claude expects total output_tokens
}
// First chunk - ALWAYS send message_start first
if (!state.messageStartSent) {
state.messageStartSent = true;
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) {
state.messageId =
chunk.extend_fields?.requestId || chunk.extend_fields?.traceId || `msg_${Date.now()}`;
}
state.model = chunk.model || "unknown";
state.nextBlockIndex = 0;
results.push({
type: "message_start",
message: {
id: state.messageId,
type: "message",
role: "assistant",
model: state.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
});
}
// Handle reasoning_content (thinking) - GLM, DeepSeek, etc.
const reasoningContent = delta?.reasoning_content || delta?.reasoning;
if (reasoningContent) {
stopTextBlock(state, results);
if (!state.thinkingBlockStarted) {
state.thinkingBlockIndex = state.nextBlockIndex++;
state.thinkingBlockStarted = true;
results.push({
type: "content_block_start",
index: state.thinkingBlockIndex,
content_block: { type: "thinking", thinking: "" },
});
}
results.push({
type: "content_block_delta",
index: state.thinkingBlockIndex,
delta: { type: "thinking_delta", thinking: reasoningContent },
});
}
// Handle regular content
if (delta?.content) {
stopThinkingBlock(state, results);
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: delta.content },
});
}
// Tool calls
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (tc.id) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
const toolBlockIndex = state.nextBlockIndex++;
state.toolCalls.set(idx, {
id: tc.id,
name: tc.function?.name || "",
blockIndex: toolBlockIndex,
});
// Strip prefix from tool name for response
let toolName = tc.function?.name || "";
if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
}
results.push({
type: "content_block_start",
index: toolBlockIndex,
content_block: {
type: "tool_use",
id: tc.id,
name: toolName,
input: {},
},
});
}
if (tc.function?.arguments) {
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
results.push({
type: "content_block_delta",
index: toolInfo.blockIndex,
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
});
}
}
}
}
// Finish
if (choice.finish_reason) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
for (const [, toolInfo] of state.toolCalls) {
results.push({
type: "content_block_stop",
index: toolInfo.blockIndex,
});
}
// Mark finish for later usage injection in stream.js
state.finishReason = choice.finish_reason;
// Use tracked usage (will be estimated in stream.js if not valid)
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
results.push({
type: "message_delta",
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
usage: finalUsage,
});
results.push({ type: "message_stop" });
}
return results.length > 0 ? results : null;
}
// Convert OpenAI finish_reason to Claude stop_reason
function convertFinishReason(reason) {
switch (reason) {
case "stop":
return "end_turn";
case "length":
return "max_tokens";
case "tool_calls":
return "tool_use";
default:
return "end_turn";
}
}
// Register
register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse);

137
open-sse/types.d.ts vendored Normal file
View File

@@ -0,0 +1,137 @@
/**
* Core type definitions for omniroute.
*
* These types describe the main data structures flowing through the proxy
* pipeline: credentials, model info, executor results, and chat parameters.
*
* Usage (JSDoc reference):
* /** @type {import("./types").ProviderCredentials} *\/
*/
// ============ Provider & Auth ============
export interface ProviderCredentials {
/** OAuth access token (short-lived) */
accessToken: string;
/** OAuth refresh token (long-lived) */
refreshToken?: string;
/** Internal connection ID */
connectionId: string;
/** User email associated with the connection */
email?: string;
/** API key (for apikey auth type) */
apiKey?: string;
/** Token expiry timestamp */
expiresAt?: string;
/** Provider-specific extra data (e.g., AWS region, auth method) */
providerSpecificData?: Record<string, unknown>;
}
export interface ModelInfo {
/** Canonical provider ID (e.g., "claude", "gemini-cli") */
provider: string;
/** Model identifier (e.g., "claude-opus-4-6") */
model: string;
/** Optional original model string from client (e.g., "cc/opus-4-6") */
originalModel?: string;
}
// ============ Executor ============
export interface ExecutorResult {
/** Whether the upstream request succeeded (2xx) */
success: boolean;
/** The HTTP Response from the upstream provider */
response: Response;
/** HTTP status code (present on failure) */
status?: number;
/** Human-readable error message (present on failure) */
error?: string;
/** Suggested retry delay in ms (present on rate-limit) */
retryAfterMs?: number;
}
// ============ Chat Pipeline ============
export interface ChatCoreParams {
/** Parsed request body */
body: Record<string, unknown>;
/** Resolved model info */
modelInfo: ModelInfo;
/** Provider credentials to use */
credentials: ProviderCredentials;
/** Request-scoped logger */
log: Logger;
/** Raw client request body (before translation) */
clientRawRequest?: Record<string, unknown>;
/** Connection ID for usage tracking */
connectionId: string;
/** API key metadata for usage attribution */
apiKeyInfo?: { id?: string; name?: string } | null;
/** Client User-Agent header */
userAgent?: string;
/** Callback when credentials are refreshed mid-request */
onCredentialsRefreshed?: (creds: ProviderCredentials) => Promise<void>;
/** Callback on successful upstream response */
onRequestSuccess?: () => Promise<void>;
}
// ============ Logger ============
export interface Logger {
debug(tag: string, message: string, data?: Record<string, unknown>): void;
info(tag: string, message: string, data?: Record<string, unknown>): void;
warn(tag: string, message: string, data?: Record<string, unknown>): void;
error(tag: string, message: string, data?: Record<string, unknown>): void;
}
export interface TaggedLogger {
debug(message: string, meta?: Record<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
}
// ============ Configuration ============
export interface ProviderConfig {
/** Primary base URL */
baseUrl?: string;
/** Multiple base URLs for failover */
baseUrls?: string[];
/** API format identifier */
format: string;
/** Default HTTP headers */
headers?: Record<string, string>;
/** OAuth client ID */
clientId?: string;
/** OAuth client secret */
clientSecret?: string;
/** Token refresh endpoint */
tokenUrl?: string;
/** Authorization endpoint */
authUrl?: string;
}
// ============ Translator ============
export interface TranslationState {
/** Source format to translate TO */
sourceFormat: string;
/** Current accumulated content */
content?: string;
/** Provider that generated the response */
provider?: string;
/** Map of tool names for translation */
toolNameMap?: Record<string, string> | null;
/** Accumulated usage data */
usage?: UsageData | null;
/** Finish reason from provider */
finishReason?: string | null;
}
export interface UsageData {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}

View File

@@ -0,0 +1,289 @@
import { detectFormat } from "../services/provider.js";
import { translateResponse, initState } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { SKIP_PATTERNS } from "../config/constants.js";
import { formatSSE } from "./stream.js";
/**
* Check for bypass patterns — return fake response without calling provider.
*
* Intentionally limited to Claude CLI requests only because:
* 1. The bypass patterns (title extraction, warmup, count) are specific to
* Claude CLI's internal protocol — other clients don't send these patterns.
* 2. False-positive bypasses would silently break real requests.
* 3. The SKIP_PATTERNS config allows user-defined patterns for any client.
*
* @param {object} body - Request body
* @param {string} model - Model name
* @param {string} userAgent - User-Agent header
* @returns {object|null} Bypass response or null to proceed normally
*/
export function handleBypassRequest(body, model, userAgent = "") {
if (!userAgent.includes("claude-cli")) return null;
if (!body.messages?.length) return null;
const messages = body.messages;
const getText = (content) => {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join(" ");
}
return "";
};
let shouldBypass = false;
// Pattern 1: Title extraction (assistant message = "{")
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === "assistant" && lastMsg.content?.[0]?.text === "{") {
shouldBypass = true;
}
// Pattern 2: Warmup
if (!shouldBypass) {
const firstText = getText(messages[0]?.content);
if (firstText === "Warmup") {
shouldBypass = true;
}
}
// Pattern 3: Count
if (!shouldBypass && messages.length === 1 && messages[0]?.role === "user") {
const firstText = getText(messages[0]?.content);
if (firstText === "count") {
shouldBypass = true;
}
}
// Pattern 4: Skip patterns
if (!shouldBypass && SKIP_PATTERNS?.length) {
const userMessages = messages.filter((m) => m.role === "user");
const userText = userMessages.map((m) => getText(m.content)).join(" ");
if (SKIP_PATTERNS.some((p) => userText.includes(p))) {
shouldBypass = true;
}
}
if (!shouldBypass) return null;
const sourceFormat = detectFormat(body);
const stream = body.stream !== false;
return stream
? createStreamingResponse(sourceFormat, model)
: createNonStreamingResponse(sourceFormat, model);
}
/**
* Create OpenAI standard format response
*/
function createOpenAIResponse(model) {
const id = `chatcmpl-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const text = "CLI Command Execution: Clear Terminal";
return {
id,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: text,
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
},
};
}
/**
* Create non-streaming response with translation
* Use translator to convert OpenAI → sourceFormat
*/
function createNonStreamingResponse(sourceFormat, model) {
const openaiResponse = createOpenAIResponse(model);
// If sourceFormat is OpenAI, return directly
if (sourceFormat === FORMATS.OPENAI) {
return {
success: true,
response: new Response(JSON.stringify(openaiResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// Use translator to convert: simulate streaming then collect all chunks
const state = initState(sourceFormat);
state.model = model;
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
const allTranslated = [];
for (const chunk of openaiChunks) {
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
if (translated?.length > 0) {
allTranslated.push(...translated);
}
}
// Flush remaining
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
if (flushed?.length > 0) {
allTranslated.push(...flushed);
}
// For non-streaming, merge all chunks into final response
const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat);
return {
success: true,
response: new Response(JSON.stringify(finalResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
/**
* Create streaming response with translation
* Use translator to convert OpenAI chunks → sourceFormat
*/
function createStreamingResponse(sourceFormat, model) {
const openaiResponse = createOpenAIResponse(model);
const state = initState(sourceFormat);
state.model = model;
// Create OpenAI streaming chunks
const openaiChunks = createOpenAIStreamingChunks(openaiResponse);
// Translate each chunk to sourceFormat using translator
const translatedChunks = [];
for (const chunk of openaiChunks) {
const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state);
if (translated?.length > 0) {
for (const item of translated) {
translatedChunks.push(formatSSE(item, sourceFormat));
}
}
}
// Flush remaining events
const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state);
if (flushed?.length > 0) {
for (const item of flushed) {
translatedChunks.push(formatSSE(item, sourceFormat));
}
}
// Add [DONE]
translatedChunks.push("data: [DONE]\n\n");
return {
success: true,
response: new Response(translatedChunks.join(""), {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
/**
* Merge translated chunks into final response object (for non-streaming)
* Takes the last complete chunk as the final response
*/
function mergeChunksToResponse(chunks, sourceFormat) {
if (!chunks || chunks.length === 0) {
return createOpenAIResponse("unknown");
}
// For most formats, the last chunk before done contains the complete response
// Find the most complete chunk (usually the last one with content)
let finalChunk = chunks[chunks.length - 1];
// For Claude format, find the message_stop or final message
if (sourceFormat === FORMATS.CLAUDE) {
const messageStop = chunks.find((c) => c.type === "message_stop");
if (messageStop) {
// Reconstruct complete message from chunks
const contentDelta = chunks.find((c) => c.type === "content_block_delta");
const messageDelta = chunks.find((c) => c.type === "message_delta");
const messageStart = chunks.find((c) => c.type === "message_start");
if (messageStart?.message) {
finalChunk = messageStart.message;
// Merge usage if available
if (messageDelta?.usage) {
finalChunk.usage = messageDelta.usage;
}
}
}
}
return finalChunk;
}
/**
* Create OpenAI streaming chunks from complete response
*/
function createOpenAIStreamingChunks(completeResponse) {
const { id, created, model, choices } = completeResponse;
const content = choices[0].message.content;
return [
// Chunk with content
{
id,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
role: "assistant",
content,
},
finish_reason: null,
},
],
},
// Final chunk with finish_reason
{
id,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
usage: completeResponse.usage,
},
];
}

View File

@@ -0,0 +1,136 @@
/**
* Cursor Checksum Utility (Jyh Cipher)
*
* Generates the x-cursor-checksum header required for Cursor API authentication.
* Based on the JavaScript implementation from Cursor IDE.
*/
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
/**
* Generate SHA-256 hash like generateHashed64Hex
* @param {string} input - Input string
* @param {string} salt - Optional salt
* @returns {string} - 64-character hex string
*/
export function generateHashed64Hex(input, salt = "") {
return crypto
.createHash("sha256")
.update(input + salt)
.digest("hex");
}
/**
* Generate session ID using UUID v5 with DNS namespace
* @param {string} authToken - Auth token
* @returns {string} - UUID string
*/
export function generateSessionId(authToken) {
return uuidv5(authToken, uuidv5.DNS);
}
/**
* Generate cursor checksum (Jyh cipher)
*
* Algorithm:
* 1. Get Unix timestamp in specific format
* 2. XOR each byte with key (starting 165)
* 3. Update key: key = (key + byte) & 0xFF
* 4. URL-safe base64 encode
* 5. Format: {base64_encoded}{machineId}
*
* @param {string} machineId - Machine ID from Cursor storage or generated
* @returns {string} - Checksum string
*/
export function generateCursorChecksum(machineId) {
// Math.floor(Date.now() / 1e6) - same as Python implementation
const timestamp = Math.floor(Date.now() / 1000000);
// Create byte array from timestamp (6 bytes, big-endian)
const byteArray = new Uint8Array([
(timestamp >> 40) & 0xff,
(timestamp >> 32) & 0xff,
(timestamp >> 24) & 0xff,
(timestamp >> 16) & 0xff,
(timestamp >> 8) & 0xff,
timestamp & 0xff,
]);
// Jyh cipher obfuscation
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff;
t = byteArray[i];
}
// URL-safe base64 encode (without padding)
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let encoded = "";
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
}
/**
* Build all Cursor API headers
*
* @param {string} accessToken - Bearer token
* @param {string} machineId - Machine ID (or will be generated from token)
* @param {boolean} ghostMode - Enable ghost mode (privacy)
* @returns {Object} - Headers object
*/
export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) {
// Clean token if it has prefix
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
// Generate machine ID if not provided
const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId");
// Generate derived values
const sessionId = generateSessionId(cleanToken);
const clientKey = generateHashed64Hex(cleanToken);
const checksum = generateCursorChecksum(effectiveMachineId);
return {
Authorization: `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": clientKey,
"x-cursor-checksum": checksum,
"x-cursor-client-version": "1.1.3",
"x-cursor-config-version": crypto.randomUUID(),
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
"x-ghost-mode": ghostMode ? "true" : "false",
"x-request-id": crypto.randomUUID(),
"x-session-id": sessionId,
Host: "api2.cursor.sh",
};
}
const cursorChecksumUtils = {
generateCursorChecksum,
buildCursorHeaders,
generateHashed64Hex,
generateSessionId,
};
export default cursorChecksumUtils;

View File

@@ -0,0 +1,681 @@
/**
* Cursor Protobuf Encoder/Decoder
* Implements ConnectRPC protobuf wire format for Cursor API
*
* Schema Version: reverse-engineered from Cursor client traffic.
* If Cursor updates their protocol, unknown field warnings will appear
* in the logs — update the FIELD map and bump the version below.
*/
import { v4 as uuidv4 } from "uuid";
import zlib from "zlib";
const DEBUG = true;
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
/**
* Schema version — bump when updating field definitions.
* Logged in warnings to help correlate unknown fields with Cursor client versions.
*/
const PROTOBUF_SCHEMA_VERSION = "1.1.3";
// ==================== SCHEMAS ====================
const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 };
const ROLE = { USER: 1, ASSISTANT: 2 };
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 };
const FIELD = {
// StreamUnifiedChatRequestWithTools (top level)
REQUEST: 1,
// StreamUnifiedChatRequest
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
UNKNOWN_4: 4,
MODEL: 5,
WEB_TOOL: 8,
UNKNOWN_13: 13,
CURSOR_SETTING: 15,
UNKNOWN_19: 19,
CONVERSATION_ID: 23,
METADATA: 26,
IS_AGENTIC: 27,
SUPPORTED_TOOLS: 29,
MESSAGE_IDS: 30,
MCP_TOOLS: 34,
LARGE_CONTEXT: 35,
UNKNOWN_38: 38,
UNIFIED_MODE: 46,
UNKNOWN_47: 47,
SHOULD_DISABLE_TOOLS: 48,
THINKING_LEVEL: 49,
UNKNOWN_51: 51,
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
// ConversationMessage
MSG_CONTENT: 1,
MSG_ROLE: 2,
MSG_ID: 13,
MSG_TOOL_RESULTS: 18,
MSG_IS_AGENTIC: 29,
MSG_UNIFIED_MODE: 47,
MSG_SUPPORTED_TOOLS: 51,
// ConversationMessage.ToolResult
TOOL_RESULT_CALL_ID: 1,
TOOL_RESULT_NAME: 2,
TOOL_RESULT_INDEX: 3,
TOOL_RESULT_RAW_ARGS: 5,
TOOL_RESULT_RESULT: 8,
// Model
MODEL_NAME: 1,
MODEL_EMPTY: 4,
// Instruction
INSTRUCTION_TEXT: 1,
// CursorSetting
SETTING_PATH: 1,
SETTING_UNKNOWN_3: 3,
SETTING_UNKNOWN_6: 6,
SETTING_UNKNOWN_8: 8,
SETTING_UNKNOWN_9: 9,
// CursorSetting.Unknown6
SETTING6_FIELD_1: 1,
SETTING6_FIELD_2: 2,
// Metadata
META_PLATFORM: 1,
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
// MessageId
MSGID_ID: 1,
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
// MCPTool
MCP_TOOL_NAME: 1,
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
// StreamUnifiedChatResponseWithTools (response)
TOOL_CALL: 1,
RESPONSE: 2,
// ClientSideToolV2Call
TOOL_ID: 3,
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
// MCPParams
MCP_TOOLS_LIST: 1,
// MCPParams.Tool (nested)
MCP_NESTED_NAME: 1,
MCP_NESTED_PARAMS: 3,
// StreamUnifiedChatResponse
RESPONSE_TEXT: 1,
THINKING: 25,
// Thinking
THINKING_TEXT: 1,
};
// Known response field numbers — used to detect unknown fields from protocol updates
const KNOWN_RESPONSE_FIELDS = new Set([
FIELD.TOOL_CALL,
FIELD.RESPONSE,
FIELD.TOOL_ID,
FIELD.TOOL_NAME,
FIELD.TOOL_RAW_ARGS,
FIELD.TOOL_IS_LAST,
FIELD.TOOL_MCP_PARAMS,
FIELD.RESPONSE_TEXT,
FIELD.THINKING,
]);
// ==================== PRIMITIVE ENCODING ====================
export function encodeVarint(value) {
const bytes = [];
while (value >= 0x80) {
bytes.push((value & 0x7f) | 0x80);
value >>>= 7;
}
bytes.push(value & 0x7f);
return new Uint8Array(bytes);
}
export function encodeField(fieldNum, wireType, value) {
const tag = (fieldNum << 3) | wireType;
const tagBytes = encodeVarint(tag);
if (wireType === WIRE_TYPE.VARINT) {
const valueBytes = encodeVarint(value);
return concatArrays(tagBytes, valueBytes);
}
if (wireType === WIRE_TYPE.LEN) {
const dataBytes =
typeof value === "string"
? new TextEncoder().encode(value)
: value instanceof Uint8Array
? value
: Buffer.isBuffer(value)
? new Uint8Array(value)
: new Uint8Array(0);
const lengthBytes = encodeVarint(dataBytes.length);
return concatArrays(tagBytes, lengthBytes, dataBytes);
}
return new Uint8Array(0);
}
function concatArrays(...arrays) {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
// ==================== MESSAGE ENCODING ====================
export function encodeToolResult(toolResult) {
const toolCallId = toolResult.tool_call_id || "";
const toolName = toolResult.name || "";
const toolIndex = toolResult.index || 0;
const rawArgs = toolResult.raw_args || "{}";
return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
);
}
export function encodeMessage(
content,
role,
messageId,
chatModeEnum = null,
isLast = false,
hasTools = false,
toolResults = []
) {
return concatArrays(
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0
? toolResults.map((tr) =>
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
)
: []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField(
FIELD.MSG_UNIFIED_MODE,
WIRE_TYPE.VARINT,
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
...(isLast && hasTools
? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [])
);
}
export function encodeInstruction(text) {
return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
}
export function encodeModel(modelName) {
return concatArrays(
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
);
}
export function encodeCursorSetting() {
const unknown6 = concatArrays(
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
);
return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1)
);
}
export function encodeMetadata() {
return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd?.() || "/"),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
);
}
export function encodeMessageId(messageId, role, summaryId = null) {
return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
);
}
export function encodeMcpTool(tool) {
const toolName = tool.function?.name || tool.name || "";
const toolDesc = tool.function?.description || tool.description || "";
const inputSchema = tool.function?.parameters || tool.input_schema || {};
return concatArrays(
...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []),
...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []),
...(Object.keys(inputSchema).length > 0
? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
: []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom")
);
}
// ==================== REQUEST BUILDING ====================
export function encodeRequest(messages, modelName, tools = [], reasoningEffort = null) {
const hasTools = tools?.length > 0;
const isAgentic = hasTools;
const formattedMessages = [];
const messageIds = [];
// Prepare messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
const msgId = uuidv4();
const isLast = i === messages.length - 1;
formattedMessages.push({
content: msg.content,
role,
messageId: msgId,
isLast,
hasTools,
toolResults: msg.tool_results || [],
});
messageIds.push({ messageId: msgId, role });
}
// Map reasoning effort to thinking level
let thinkingLevel = THINKING_LEVEL.UNSPECIFIED;
if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM;
else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH;
// Build request
return concatArrays(
// Messages
...formattedMessages.map((fm) =>
encodeField(
FIELD.MESSAGES,
WIRE_TYPE.LEN,
encodeMessage(
fm.content,
fm.role,
fm.messageId,
null,
fm.isLast,
fm.hasTools,
fm.toolResults
)
)
),
// Static fields
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, uuidv4()),
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
// Tool-related fields
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
...(isAgentic ? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []),
// Message IDs
...messageIds.map((mid) =>
encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
),
// MCP Tools
...(tools?.length > 0
? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
: []),
// Mode fields
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField(
FIELD.UNIFIED_MODE,
WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? "Agent" : "Ask")
);
}
export function buildChatRequest(messages, modelName, tools = [], reasoningEffort = null) {
return encodeField(
FIELD.REQUEST,
WIRE_TYPE.LEN,
encodeRequest(messages, modelName, tools, reasoningEffort)
);
}
export function wrapConnectRPCFrame(payload, compress = false) {
let finalPayload = payload;
let flags = 0x00;
if (compress) {
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
flags = 0x01;
}
const frame = new Uint8Array(5 + finalPayload.length);
frame[0] = flags;
frame[1] = (finalPayload.length >> 24) & 0xff;
frame[2] = (finalPayload.length >> 16) & 0xff;
frame[3] = (finalPayload.length >> 8) & 0xff;
frame[4] = finalPayload.length & 0xff;
frame.set(finalPayload, 5);
return frame;
}
export function generateCursorBody(messages, modelName, tools = [], reasoningEffort = null) {
log(
"BODY",
`Generating: ${messages.length} msgs, model=${modelName}, tools=${tools.length}, reasoning=${reasoningEffort || "none"}`
);
const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort);
const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests
log("BODY", `Protobuf=${protobuf.length}B, Framed=${framed.length}B`);
return framed;
}
// ==================== PRIMITIVE DECODING ====================
export function decodeVarint(buffer, offset) {
let result = 0;
let shift = 0;
let pos = offset;
while (pos < buffer.length) {
const b = buffer[pos];
result |= (b & 0x7f) << shift;
pos++;
if (!(b & 0x80)) break;
shift += 7;
}
return [result, pos];
}
export function decodeField(buffer, offset) {
if (offset >= buffer.length) return [null, null, null, offset];
const [tag, pos1] = decodeVarint(buffer, offset);
const fieldNum = tag >> 3;
const wireType = tag & 0x07;
let value;
let pos = pos1;
if (wireType === WIRE_TYPE.VARINT) {
[value, pos] = decodeVarint(buffer, pos);
} else if (wireType === WIRE_TYPE.LEN) {
const [length, pos2] = decodeVarint(buffer, pos);
value = buffer.slice(pos2, pos2 + length);
pos = pos2 + length;
} else if (wireType === WIRE_TYPE.FIXED64) {
value = buffer.slice(pos, pos + 8);
pos += 8;
} else if (wireType === WIRE_TYPE.FIXED32) {
value = buffer.slice(pos, pos + 4);
pos += 4;
} else {
value = null;
}
return [fieldNum, wireType, value, pos];
}
export function decodeMessage(data) {
const fields = new Map();
let pos = 0;
while (pos < data.length) {
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
if (fieldNum === null) break;
if (!fields.has(fieldNum)) fields.set(fieldNum, []);
fields.get(fieldNum).push({ wireType, value });
pos = newPos;
}
return fields;
}
// ==================== RESPONSE PARSING ====================
export function parseConnectRPCFrame(buffer) {
if (buffer.length < 5) return null;
const flags = buffer[0];
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
if (buffer.length < 5 + length) return null;
let payload = buffer.slice(5, 5 + length);
// Decompress if gzip
if (flags === 0x01) {
try {
payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload)));
} catch (err) {
log("PARSE", `Decompression failed: ${err.message}`);
}
}
return { flags, length, payload, consumed: 5 + length };
}
function extractToolCall(toolCallData) {
const toolCall = decodeMessage(toolCallData);
let toolCallId = "";
let toolName = "";
let rawArgs = "";
let isLast = false;
// Extract tool call ID
if (toolCall.has(FIELD.TOOL_ID)) {
const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value);
toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line
}
// Extract tool name
if (toolCall.has(FIELD.TOOL_NAME)) {
toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
}
// Extract is_last flag
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0;
}
// Extract MCP params - nested real tool info
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
try {
const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value);
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value);
if (tool.has(FIELD.MCP_NESTED_NAME)) {
toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value);
}
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value);
}
}
} catch (err) {
log("EXTRACT", `MCP parse error: ${err.message}`);
}
}
// Fallback to raw_args
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value);
}
if (toolCallId && toolName) {
return {
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: rawArgs || "{}",
},
isLast,
};
}
return null;
}
function extractTextAndThinking(responseData) {
const nested = decodeMessage(responseData);
let text = null;
let thinking = null;
// Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) {
text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value);
}
// Extract thinking
if (nested.has(FIELD.THINKING)) {
try {
const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value);
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value);
}
} catch (err) {
log("EXTRACT", `Thinking parse error: ${err.message}`);
}
}
return { text, thinking };
}
export function extractTextFromResponse(payload) {
try {
const fields = decodeMessage(payload);
// Warn about unknown field numbers — may indicate a Cursor protocol update
for (const fieldNum of fields.keys()) {
if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) {
log(
"SCHEMA",
`Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.`
);
}
}
// Field 1: ClientSideToolV2Call
if (fields.has(FIELD.TOOL_CALL)) {
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
if (toolCall) {
log("EXTRACT", `Tool call: ${toolCall.function.name}`);
return { text: null, error: null, toolCall, thinking: null };
}
}
// Field 2: StreamUnifiedChatResponse
if (fields.has(FIELD.RESPONSE)) {
const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value);
if (text || thinking) {
return { text, error: null, toolCall: null, thinking };
}
}
return { text: null, error: null, toolCall: null, thinking: null };
} catch (err) {
// Graceful fallback — return raw payload instead of crashing
log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`);
return {
text: null,
error: null,
toolCall: null,
thinking: null,
raw: Buffer.from(payload).toString("base64"),
decodeError: err.message,
};
}
}
// ==================== EXPORTS ====================
const cursorProtobufUtils = {
encodeVarint,
encodeField,
encodeMessage,
buildChatRequest,
wrapConnectRPCFrame,
generateCursorBody,
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse,
};
export default cursorProtobufUtils;

185
open-sse/utils/error.js Normal file
View File

@@ -0,0 +1,185 @@
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.js";
/**
* Build OpenAI-compatible error response body
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @returns {object} Error response object
*/
export function buildErrorBody(statusCode, message) {
const errorInfo =
ERROR_TYPES[statusCode] ||
(statusCode >= 500
? { type: "server_error", code: "internal_server_error" }
: { type: "invalid_request_error", code: "" });
return {
error: {
message: message || DEFAULT_ERROR_MESSAGES[statusCode] || "An error occurred",
type: errorInfo.type,
code: errorInfo.code,
},
};
}
/**
* Create error Response object (for non-streaming)
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @returns {Response} HTTP Response object
*/
export function errorResponse(statusCode, message) {
return new Response(JSON.stringify(buildErrorBody(statusCode, message)), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
/**
* Write error to SSE stream (for streaming)
* @param {WritableStreamDefaultWriter} writer - Stream writer
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
*/
export async function writeStreamError(writer, statusCode, message) {
const errorBody = buildErrorBody(statusCode, message);
const encoder = new TextEncoder();
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
}
/**
* Parse Antigravity error message to extract retry time
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
* @param {string} message - Error message
* @returns {number|null} Retry time in milliseconds, or null if not found
*/
export function parseAntigravityRetryTime(message) {
if (typeof message !== "string") return null;
// Match patterns like: 2h7m23s, 5m30s, 45s, 1h20m, etc.
const match = message.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
// Extract hours
if (match[1]) {
const hours = parseInt(match[1]);
totalMs += hours * 60 * 60 * 1000;
}
// Extract minutes
if (match[2]) {
const minutes = parseInt(match[2]);
totalMs += minutes * 60 * 1000;
}
// Extract seconds
if (match[3]) {
const seconds = parseInt(match[3]);
totalMs += seconds * 1000;
}
return totalMs > 0 ? totalMs : null;
}
/**
* Parse upstream provider error response
* @param {Response} response - Fetch response from provider
* @param {string} provider - Provider name (for Antigravity-specific parsing)
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null}>}
*/
export async function parseUpstreamError(response, provider = null) {
let message = "";
let retryAfterMs = null;
try {
const text = await response.text();
// Try parse as JSON
try {
const json = JSON.parse(text);
message = json.error?.message || json.message || json.error || text;
} catch {
message = text;
}
} catch {
message = `Upstream error: ${response.status}`;
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
// Parse Antigravity-specific retry time from error message
if (provider === "antigravity" && response.status === 429) {
retryAfterMs = parseAntigravityRetryTime(messageStr);
}
return {
statusCode: response.status,
message: messageStr,
retryAfterMs,
};
}
/**
* Create error result for chatCore handler
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
*/
export function createErrorResult(statusCode, message, retryAfterMs = null) {
const result = {
success: false,
status: statusCode,
error: message,
response: errorResponse(statusCode, message),
};
// Add retryAfterMs if available (for Antigravity quota errors)
if (retryAfterMs) {
result.retryAfterMs = retryAfterMs;
}
return result;
}
/**
* Create unavailable response when all accounts are rate limited
* @param {number} statusCode - Original error status code
* @param {string} message - Error message (without retry info)
* @param {string} retryAfter - ISO timestamp when earliest account becomes available
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
* @returns {Response}
*/
export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) {
const retryAfterSec = Math.max(
Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000),
1
);
const msg = `${message} (${retryAfterHuman})`;
return new Response(JSON.stringify({ error: { message: msg } }), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
},
});
}
/**
* Format provider error with context
* @param {Error} error - Original error
* @param {string} provider - Provider name
* @param {string} model - Model name
* @param {number|string} statusCode - HTTP status code or error code
* @returns {string} Formatted error message
*/
export function formatProviderError(error, provider, model, statusCode) {
const code = statusCode || error.code || "FETCH_FAILED";
const message = error.message || "Unknown error";
return `[${code}]: ${message}`;
}

167
open-sse/utils/logger.js Normal file
View File

@@ -0,0 +1,167 @@
/**
* Structured console logger utility for omniroute.
*
* Provides consistent, machine-parseable log output across the codebase.
* Supports two output formats controlled by LOG_FORMAT env var:
* - "text" (default): [LEVEL] [TAG] message {metadata}
* - "json": Single-line JSON objects for log aggregators
*
* Usage:
* import { logger, createLogger, generateRequestId } from "../utils/logger.js";
*
* // Tag-based (simple — for services/utilities):
* const log = logger("CHAT");
* log.info("Request received", { model: "claude-4" });
*
* // Request-scoped (with correlation ID — for request pipelines):
* const reqLog = createLogger(generateRequestId());
* reqLog.info("AUTH", "Token refreshed", { provider: "claude" });
*
* Environment variables:
* LOG_LEVEL — minimum level: debug | info | warn | error (default: info)
* LOG_FORMAT — output format: text | json (default: text)
*/
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLevel =
LEVELS[((typeof process !== "undefined" && process.env?.LOG_LEVEL) || "info").toLowerCase()] ??
LEVELS.info;
const jsonFormat = typeof process !== "undefined" && process.env?.LOG_FORMAT === "json";
let requestCounter = 0;
/**
* Generate a unique request ID for log correlation.
* Format: req_<timestamp>_<counter>
* @returns {string}
*/
export function generateRequestId() {
return `req_${Date.now()}_${++requestCounter}`;
}
/**
* Mask a sensitive key for safe logging.
* Shows first 6 and last 4 characters: "sk-abc123...xyz9"
* @param {string} key
* @returns {string}
*/
export function maskKey(key) {
if (!key || key.length < 12) return "(redacted)";
return `${key.slice(0, 6)}...${key.slice(-4)}`;
}
/**
* Get the correct console method for a log level.
* @param {string} level
* @returns {Function}
*/
function getConsoleFn(level) {
switch (level) {
case "debug":
return console.debug;
case "warn":
return console.warn;
case "error":
return console.error;
default:
return console.log;
}
}
/**
* Format metadata object as compact JSON string for log output.
* Omits keys with null/undefined values to keep logs clean.
* @param {object} [meta] - Optional metadata
* @returns {string} Formatted metadata string or empty string
*/
function formatMeta(meta) {
if (!meta || typeof meta !== "object") return "";
const cleaned = {};
for (const [k, v] of Object.entries(meta)) {
if (v !== undefined && v !== null) cleaned[k] = v;
}
return Object.keys(cleaned).length > 0 ? ` ${JSON.stringify(cleaned)}` : "";
}
/**
* Create a tagged logger instance (simple API for services and utilities).
* @param {string} tag - Log category tag (e.g. "CHAT", "AUTH", "STREAM")
* @returns {{ debug: Function, info: Function, warn: Function, error: Function }}
*/
export function logger(tag) {
const emit = (level, message, meta) => {
if (LEVELS[level] < currentLevel) return;
const consoleFn = getConsoleFn(level);
if (jsonFormat) {
const entry = {
ts: new Date().toISOString(),
level,
tag,
msg: message,
};
if (meta && typeof meta === "object" && Object.keys(meta).length > 0) {
entry.data = meta;
}
consoleFn(JSON.stringify(entry));
} else {
consoleFn(`[${level.toUpperCase()}] [${tag}] ${message}${formatMeta(meta)}`);
}
};
return {
debug: (message, meta) => emit("debug", message, meta),
info: (message, meta) => emit("info", message, meta),
warn: (message, meta) => emit("warn", message, meta),
error: (message, meta) => emit("error", message, meta),
};
}
/**
* Create a request-scoped logger with correlation ID.
* All methods accept (tag, message, data?) for structured logging.
*
* @param {string} [requestId] - Unique request ID for correlation
* @returns {{ debug, info, warn, error }}
*/
export function createLogger(requestId = null) {
const emit = (level, tag, message, data) => {
if (LEVELS[level] < currentLevel) return;
const consoleFn = getConsoleFn(level);
if (jsonFormat) {
const entry = {
ts: new Date().toISOString(),
level,
tag,
msg: message,
};
if (requestId) entry.reqId = requestId;
if (data && typeof data === "object" && Object.keys(data).length > 0) {
entry.data = data;
}
consoleFn(JSON.stringify(entry));
} else {
const ts = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
const prefix = requestId ? `[${requestId}]` : "";
const dataStr = formatMeta(data);
consoleFn(`${ts} ${prefix}[${tag}] ${message}${dataStr}`);
}
};
return {
debug: (tag, msg, data) => emit("debug", tag, msg, data),
info: (tag, msg, data) => emit("info", tag, msg, data),
warn: (tag, msg, data) => emit("warn", tag, msg, data),
error: (tag, msg, data) => emit("error", tag, msg, data),
};
}
/**
* Module-level default logger (no requestId — for startup/config messages).
*/
export const defaultLogger = createLogger();
export default logger;

View File

@@ -0,0 +1,74 @@
/**
* Network Proxy Resolver
*
* Resolves the outbound proxy URL for a given provider.
* Precedence: provider-specific > global > environment variables
*
* Usage:
* import { resolveProxy } from "open-sse/utils/networkProxy.js";
* const proxyUrl = await resolveProxy("openai");
*/
let _cachedConfig = null;
let _cacheExpiry = 0;
/**
* Get proxy config from localDb (with caching)
*/
async function getConfig() {
const now = Date.now();
if (_cachedConfig && now < _cacheExpiry) return _cachedConfig;
try {
const { getProxyConfig } = await import("../../src/lib/localDb.js");
_cachedConfig = await getProxyConfig();
_cacheExpiry = now + 30_000; // Cache for 30s
return _cachedConfig;
} catch {
return { global: null, providers: {} };
}
}
/**
* Resolve proxy URL for a given provider
* @param {string} providerId - Provider ID (e.g., "openai", "anthropic")
* @returns {string|null} Proxy URL or null if no proxy configured
*/
export async function resolveProxy(providerId) {
const config = await getConfig();
// 1. Provider-specific proxy
if (providerId && config.providers?.[providerId]) {
return config.providers[providerId];
}
// 2. Global proxy
if (config.global) {
return config.global;
}
// 3. Environment variables
const envProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY;
if (envProxy) {
// Check NO_PROXY
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
// Simple check: if providerId is in NO_PROXY list, skip
if (noProxy && providerId) {
const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase());
if (noProxyList.includes(providerId.toLowerCase())) {
return null;
}
}
return envProxy;
}
return null;
}
/**
* Invalidate the proxy config cache (call after config changes)
*/
export function invalidateProxyCache() {
_cachedConfig = null;
_cacheExpiry = 0;
}

View File

@@ -0,0 +1,90 @@
// Transform OpenAI SSE stream to Ollama JSON lines format
export function transformToOllama(response, model) {
let buffer = "";
let pendingToolCalls = {};
const transform = new TransformStream({
transform(chunk, controller) {
const text = new TextDecoder().decode(chunk);
buffer += text;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") {
const ollamaEnd =
JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) +
"\n";
controller.enqueue(new TextEncoder().encode(ollamaEnd));
return;
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta || {};
const content = delta.content || "";
const toolCalls = delta.tool_calls;
if (toolCalls) {
for (const tc of toolCalls) {
const idx = tc.index;
if (!pendingToolCalls[idx]) {
pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } };
}
if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name;
if (tc.function?.arguments)
pendingToolCalls[idx].function.arguments += tc.function.arguments;
}
}
if (content) {
const ollama =
JSON.stringify({ model, message: { role: "assistant", content }, done: false }) +
"\n";
controller.enqueue(new TextEncoder().encode(ollama));
}
const finishReason = parsed.choices?.[0]?.finish_reason;
if (finishReason === "tool_calls" || finishReason === "stop") {
const toolCallsArr = Object.values(pendingToolCalls);
if (toolCallsArr.length > 0) {
const formattedCalls = toolCallsArr.map((tc) => ({
function: {
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || "{}"),
},
}));
const ollama =
JSON.stringify({
model,
message: { role: "assistant", content: "", tool_calls: formattedCalls },
done: true,
}) + "\n";
controller.enqueue(new TextEncoder().encode(ollama));
pendingToolCalls = {};
} else if (finishReason === "stop") {
const ollamaEnd =
JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) +
"\n";
controller.enqueue(new TextEncoder().encode(ollamaEnd));
}
}
} catch (e) {
// Silently ignore parse errors
}
}
},
flush(controller) {
const ollamaEnd =
JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n";
controller.enqueue(new TextEncoder().encode(ollamaEnd));
},
});
return new Response(response.body.pipeThrough(transform), {
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" },
});
}

View File

@@ -0,0 +1,129 @@
import { ProxyAgent } from "undici";
import { socksDispatcher } from "fetch-socks";
const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache");
const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]);
function getDispatcherCache() {
if (!globalThis[DISPATCHER_CACHE_KEY]) {
globalThis[DISPATCHER_CACHE_KEY] = new Map();
}
return globalThis[DISPATCHER_CACHE_KEY];
}
function defaultPortForProtocol(protocol) {
if (protocol === "https:" || protocol === "wss:") return "443";
if (protocol === "socks5:") return "1080";
return "8080";
}
function normalizePort(port, protocol) {
if (!port) return defaultPortForProtocol(protocol);
const parsed = Number(port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("[ProxyDispatcher] Invalid proxy port");
}
return String(parsed);
}
export function isSocks5ProxyEnabled() {
return process.env.ENABLE_SOCKS5_PROXY === "true";
}
export function proxyUrlForLogs(proxyUrl) {
const parsed = new URL(proxyUrl);
const port = parsed.port || defaultPortForProtocol(parsed.protocol);
return `${parsed.protocol}//${parsed.hostname}:${port}`;
}
export function normalizeProxyUrl(
proxyUrl,
source = "proxy",
{ allowSocks5 = isSocks5ProxyEnabled() } = {}
) {
let parsed;
try {
parsed = new URL(proxyUrl);
} catch {
throw new Error(`[ProxyDispatcher] Invalid ${source} URL`);
}
if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
throw new Error(
`[ProxyDispatcher] Unsupported ${source} protocol: ${parsed.protocol.replace(":", "")}`
);
}
if (parsed.protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
);
}
if (!parsed.hostname) {
throw new Error(`[ProxyDispatcher] Invalid ${source} host`);
}
parsed.port = normalizePort(parsed.port, parsed.protocol);
return parsed.toString();
}
export function proxyConfigToUrl(proxyConfig, { allowSocks5 = isSocks5ProxyEnabled() } = {}) {
if (!proxyConfig) return null;
if (typeof proxyConfig === "string") {
return normalizeProxyUrl(proxyConfig, "context proxy", { allowSocks5 });
}
if (typeof proxyConfig !== "object" || Array.isArray(proxyConfig)) {
throw new Error("[ProxyDispatcher] Invalid context proxy config");
}
const type = String(proxyConfig.type || "http").toLowerCase();
const protocol = `${type}:`;
if (!SUPPORTED_PROTOCOLS.has(protocol)) {
throw new Error(`[ProxyDispatcher] Unsupported context proxy protocol: ${type}`);
}
if (protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
);
}
if (!proxyConfig.host) {
throw new Error("[ProxyDispatcher] Context proxy host is required");
}
const port = normalizePort(proxyConfig.port, protocol);
const proxyUrl = new URL(`${type}://${proxyConfig.host}:${port}`);
if (proxyConfig.username) {
proxyUrl.username = proxyConfig.username;
proxyUrl.password = proxyConfig.password || "";
}
return normalizeProxyUrl(proxyUrl.toString(), "context proxy", { allowSocks5 });
}
export function createProxyDispatcher(proxyUrl) {
const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher");
const dispatcherCache = getDispatcherCache();
let dispatcher = dispatcherCache.get(normalizedUrl);
if (dispatcher) return dispatcher;
const parsed = new URL(normalizedUrl);
if (parsed.protocol === "socks5:") {
const socksOptions = {
type: 5,
host: parsed.hostname,
port: Number(normalizePort(parsed.port, parsed.protocol)),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
dispatcher = socksDispatcher(socksOptions);
} else {
dispatcher = new ProxyAgent(normalizedUrl);
}
dispatcherCache.set(normalizedUrl, dispatcher);
return dispatcher;
}

View File

@@ -0,0 +1,156 @@
import { AsyncLocalStorage } from "node:async_hooks";
import {
createProxyDispatcher,
normalizeProxyUrl,
proxyConfigToUrl,
proxyUrlForLogs,
} from "./proxyDispatcher.js";
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state");
function getPatchState() {
if (!globalThis[PATCH_STATE_KEY]) {
globalThis[PATCH_STATE_KEY] = {
originalFetch: globalThis.fetch,
proxyContext: new AsyncLocalStorage(),
isPatched: false,
};
}
return globalThis[PATCH_STATE_KEY];
}
const patchState = getPatchState();
const originalFetch = patchState.originalFetch;
const proxyContext = patchState.proxyContext;
function noProxyMatch(targetUrl) {
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
if (!noProxy) return false;
let target;
try {
target = new URL(targetUrl);
} catch {
return false;
}
const hostname = target.hostname.toLowerCase();
const port = target.port || (target.protocol === "https:" ? "443" : "80");
const patterns = noProxy
.split(",")
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern === "*") return true;
const [patternHost, patternPort] = pattern.split(":");
if (patternPort && patternPort !== port) return false;
if (!patternHost) return false;
if (patternHost.startsWith(".")) {
return hostname.endsWith(patternHost) || hostname === patternHost.slice(1);
}
return hostname === patternHost || hostname.endsWith(`.${patternHost}`);
});
}
function resolveEnvProxyUrl(targetUrl) {
if (noProxyMatch(targetUrl)) return null;
let protocol;
try {
protocol = new URL(targetUrl).protocol;
} catch {
return null;
}
const proxyUrl =
protocol === "https:"
? process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy
: process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy;
if (!proxyUrl) return null;
return normalizeProxyUrl(proxyUrl, "environment proxy");
}
function resolveProxyForRequest(targetUrl) {
const contextProxy = proxyContext.getStore();
if (contextProxy) {
return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) };
}
const envProxyUrl = resolveEnvProxyUrl(targetUrl);
if (envProxyUrl) {
return { source: "env", proxyUrl: envProxyUrl };
}
return { source: "direct", proxyUrl: null };
}
function getTargetUrl(input) {
if (typeof input === "string") return input;
if (input && typeof input.url === "string") return input.url;
return String(input);
}
export async function runWithProxyContext(proxyConfig, fn) {
if (typeof fn !== "function") {
throw new TypeError("runWithProxyContext requires a callback function");
}
const resolvedProxyUrl = proxyConfig ? proxyConfigToUrl(proxyConfig) : null;
return proxyContext.run(proxyConfig || null, async () => {
if (resolvedProxyUrl) {
console.log(
`[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}`
);
}
return fn();
});
}
async function patchedFetch(input, options = {}) {
if (options?.dispatcher) {
return originalFetch(input, options);
}
const targetUrl = getTargetUrl(input);
let resolved;
try {
resolved = resolveProxyForRequest(targetUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ProxyFetch] Proxy configuration error: ${message}`);
throw error;
}
const { source, proxyUrl } = resolved;
if (!proxyUrl) {
return originalFetch(input, options);
}
try {
const dispatcher = createProxyDispatcher(proxyUrl);
return await originalFetch(input, { ...options, dispatcher });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`);
throw error;
}
}
if (!isCloud && !patchState.isPatched) {
globalThis.fetch = patchedFetch;
patchState.isPatched = true;
}
export default isCloud ? originalFetch : patchedFetch;

View File

@@ -0,0 +1,251 @@
// Check if running in Node.js environment (has fs module)
const isNode =
typeof process !== "undefined" && process.versions?.node && typeof window === "undefined";
// Check if logging is enabled via environment variable (default: false)
const LOGGING_ENABLED =
typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === "true";
let fs = null;
let path = null;
let LOGS_DIR = null;
// Lazy load Node.js modules (avoid top-level await)
async function ensureNodeModules() {
if (!isNode || !LOGGING_ENABLED || fs) return;
try {
fs = await import("fs");
path = await import("path");
LOGS_DIR = path.join(
typeof process !== "undefined" && process.cwd ? process.cwd() : ".",
"logs"
);
} catch {
// Running in non-Node environment (Worker, Browser, etc.)
}
}
// Format timestamp for folder name: 20251228_143045
function formatTimestamp(date = new Date()) {
const pad = (n) => String(n).padStart(2, "0");
const y = date.getFullYear();
const m = pad(date.getMonth() + 1);
const d = pad(date.getDate());
const h = pad(date.getHours());
const min = pad(date.getMinutes());
const s = pad(date.getSeconds());
return `${y}${m}${d}_${h}${min}${s}`;
}
// Create log session folder: {sourceFormat}_{targetFormat}_{model}_{timestamp}
async function createLogSession(sourceFormat, targetFormat, model) {
await ensureNodeModules();
if (!fs || !LOGS_DIR) return null;
try {
await fs.promises.mkdir(LOGS_DIR, { recursive: true });
const timestamp = formatTimestamp();
const safeModel = (model || "unknown").replace(/[/:]/g, "-");
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
const sessionPath = path.join(LOGS_DIR, folderName);
await fs.promises.mkdir(sessionPath, { recursive: true });
return sessionPath;
} catch (err) {
console.log("[LOG] Failed to create log session:", err.message);
return null;
}
}
// Write JSON file (async, fire-and-forget)
function writeJsonFile(sessionPath, filename, data) {
if (!fs || !sessionPath) return;
const filePath = path.join(sessionPath, filename);
fs.promises
.writeFile(filePath, JSON.stringify(data, null, 2))
.catch((err) => console.log(`[LOG] Failed to write ${filename}:`, err.message));
}
// Mask sensitive data in headers before writing to log files
function maskSensitiveHeaders(headers) {
if (!headers) return {};
const masked = { ...headers };
const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"];
for (const key of Object.keys(masked)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) {
const value = masked[key];
if (value && value.length > 20) {
masked[key] = value.slice(0, 10) + "..." + value.slice(-5);
}
}
}
return masked;
}
// No-op logger when logging is disabled
function createNoOpLogger() {
return {
sessionPath: null,
logClientRawRequest() {},
logRawRequest() {},
logOpenAIRequest() {},
logTargetRequest() {},
logProviderResponse() {},
appendProviderChunk() {},
appendOpenAIChunk() {},
logConvertedResponse() {},
appendConvertedChunk() {},
logError() {},
};
}
/**
* Create a new log session and return logger functions
* @param {string} sourceFormat - Source format from client (claude, openai, etc.)
* @param {string} targetFormat - Target format to provider (antigravity, gemini-cli, etc.)
* @param {string} model - Model name
* @returns {Promise<object>} Promise that resolves to logger object with methods to log each stage
*/
export async function createRequestLogger(sourceFormat, targetFormat, model) {
// Return no-op logger if logging is disabled
if (!LOGGING_ENABLED) {
return createNoOpLogger();
}
// Wait for session to be created before returning logger
const sessionPath = await createLogSession(sourceFormat, targetFormat, model);
return {
get sessionPath() {
return sessionPath;
},
// 1. Log client raw request (before any conversion)
logClientRawRequest(endpoint, body, headers = {}) {
writeJsonFile(sessionPath, "1_req_client.json", {
timestamp: new Date().toISOString(),
endpoint,
headers: maskSensitiveHeaders(headers),
body,
});
},
// 2. Log raw request from client (after initial conversion like responsesApi)
logRawRequest(body, headers = {}) {
writeJsonFile(sessionPath, "2_req_source.json", {
timestamp: new Date().toISOString(),
headers: maskSensitiveHeaders(headers),
body,
});
},
// 3. Log OpenAI intermediate format (source → openai)
logOpenAIRequest(body) {
writeJsonFile(sessionPath, "3_req_openai.json", {
timestamp: new Date().toISOString(),
body,
});
},
// 4. Log target format request (openai → target)
logTargetRequest(url, headers, body) {
writeJsonFile(sessionPath, "4_req_target.json", {
timestamp: new Date().toISOString(),
url,
headers: maskSensitiveHeaders(headers),
body,
});
},
// 5. Log provider response (for non-streaming or error)
logProviderResponse(status, statusText, headers, body) {
const filename = "5_res_provider.json";
writeJsonFile(sessionPath, filename, {
timestamp: new Date().toISOString(),
status,
statusText,
headers: headers
? typeof headers.entries === "function"
? Object.fromEntries(headers.entries())
: headers
: {},
body,
});
},
// 5. Append streaming chunk to provider response (async)
appendProviderChunk(chunk) {
if (!fs || !sessionPath) return;
const filePath = path.join(sessionPath, "5_res_provider.txt");
fs.promises.appendFile(filePath, chunk).catch(() => {});
},
// 6. Append OpenAI intermediate chunks (async)
appendOpenAIChunk(chunk) {
if (!fs || !sessionPath) return;
const filePath = path.join(sessionPath, "6_res_openai.txt");
fs.promises.appendFile(filePath, chunk).catch(() => {});
},
// 7. Log converted response to client (for non-streaming)
logConvertedResponse(body) {
writeJsonFile(sessionPath, "7_res_client.json", {
timestamp: new Date().toISOString(),
body,
});
},
// 7b. Append streaming chunk to converted response (async)
appendConvertedChunk(chunk) {
if (!fs || !sessionPath) return;
const filePath = path.join(sessionPath, "7_res_client.txt");
fs.promises.appendFile(filePath, chunk).catch(() => {});
},
// 8. Log error
logError(error, requestBody = null) {
writeJsonFile(sessionPath, "6_error.json", {
timestamp: new Date().toISOString(),
error: error?.message || String(error),
stack: error?.stack,
requestBody,
});
},
};
}
// Legacy logError (kept for backward compatibility, converted to async)
export function logError(provider, { error, url, model, requestBody }) {
if (!fs || !LOGS_DIR) return;
const writeLog = async () => {
try {
await fs.promises.mkdir(LOGS_DIR, { recursive: true });
const date = new Date().toISOString().split("T")[0];
const logPath = path.join(LOGS_DIR, `${provider}-${date}.log`);
const logEntry = {
timestamp: new Date().toISOString(),
type: "error",
provider,
model,
url,
error: error?.message || String(error),
stack: error?.stack,
requestBody,
};
await fs.promises.appendFile(logPath, JSON.stringify(logEntry) + "\n");
} catch (err) {
console.log("[LOG] Failed to write error log:", err.message);
}
};
writeLog();
}

465
open-sse/utils/stream.js Normal file
View File

@@ -0,0 +1,465 @@
import { translateResponse, initState } from "../translator/index.js";
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 { STREAM_IDLE_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.js";
export { COLORS, formatSSE };
// Note: TextDecoder/TextEncoder are created per-stream inside createSSEStream()
// to avoid shared state issues with concurrent streams (TextDecoder with {stream:true}
// maintains internal buffering state between decode() calls).
/**
* Stream modes
*/
const STREAM_MODE = {
TRANSLATE: "translate", // Full translation between formats
PASSTHROUGH: "passthrough", // No translation, normalize output, extract usage
};
/**
* Create unified SSE transform stream with idle timeout protection.
* If the upstream provider stops sending data for STREAM_IDLE_TIMEOUT_MS,
* the stream emits an error event and closes to prevent indefinite hanging.
*
* @param {object} options
* @param {string} options.mode - Stream mode: translate, passthrough
* @param {string} options.targetFormat - Provider format (for translate mode)
* @param {string} options.sourceFormat - Client format (for translate mode)
* @param {string} options.provider - Provider name
* @param {object} options.reqLogger - Request logger instance
* @param {string} options.model - Model name
* @param {string} options.connectionId - Connection ID for usage tracking
* @param {object|null} options.apiKeyInfo - API key metadata for usage attribution
* @param {object} options.body - Request body (for input token estimation)
* @param {function} options.onComplete - Callback when stream finishes: ({ status, usage }) => void
*/
export function createSSEStream(options = {}) {
const {
mode = STREAM_MODE.TRANSLATE,
targetFormat,
sourceFormat,
provider = null,
reqLogger = null,
toolNameMap = null,
model = null,
connectionId = null,
apiKeyInfo = null,
body = null,
onComplete = null,
} = options;
let buffer = "";
let usage = null;
// State for translate mode
const state =
mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap } : null;
// Track content length for usage estimation (both modes)
let totalContentLength = 0;
// Guard against duplicate [DONE] events — ensures exactly one per stream
let doneSent = false;
// Per-stream instances to avoid shared state with concurrent streams
const decoder = new TextDecoder();
const encoder = new TextEncoder();
// Idle timeout state — closes stream if provider stops sending data
let lastChunkTime = Date.now();
let idleTimer = null;
let streamTimedOut = false;
return new TransformStream(
{
start(controller) {
// Start idle watchdog — checks every 10s if provider has stopped sending
if (STREAM_IDLE_TIMEOUT_MS > 0) {
idleTimer = setInterval(() => {
if (!streamTimedOut && Date.now() - lastChunkTime > STREAM_IDLE_TIMEOUT_MS) {
streamTimedOut = true;
clearInterval(idleTimer);
idleTimer = null;
const timeoutMsg = `[STREAM] Idle timeout: no data from ${provider || "provider"} for ${STREAM_IDLE_TIMEOUT_MS}ms (model: ${model || "unknown"})`;
console.warn(timeoutMsg);
trackPendingRequest(model, provider, connectionId, false);
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.GATEWAY_TIMEOUT}`,
}).catch(() => {});
const timeoutError = new Error(timeoutMsg);
timeoutError.name = "StreamIdleTimeoutError";
controller.error(timeoutError);
}
}, 10_000);
}
},
transform(chunk, controller) {
if (streamTimedOut) return;
lastChunkTime = Date.now();
const text = decoder.decode(chunk, { stream: true });
buffer += text;
reqLogger?.appendProviderChunk?.(text);
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
// Passthrough mode: normalize and forward
if (mode === STREAM_MODE.PASSTHROUGH) {
let output;
let injectedUsage = false;
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
const parsed = JSON.parse(trimmed.slice(5).trim());
const idFixed = fixInvalidId(parsed);
if (!hasValuableContent(parsed, FORMATS.OPENAI)) {
continue;
}
const delta = parsed.choices?.[0]?.delta;
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
}
const extracted = extractUsage(parsed);
if (extracted) {
usage = extracted;
}
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n`;
usage = estimated;
injectedUsage = true;
} else if (isFinishChunk && usage) {
const buffered = addBufferToUsage(usage);
parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
} else if (idFixed) {
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
}
} catch {}
}
if (!injectedUsage) {
if (line.startsWith("data:") && !line.startsWith("data: ")) {
output = "data: " + line.slice(5) + "\n";
} else {
output = line + "\n";
}
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
continue;
}
// Translate mode
if (!trimmed) continue;
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
if (parsed && parsed.done) {
if (!doneSent) {
doneSent = true;
const output = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
continue;
}
// Track content length for estimation (from various formats)
// Include both regular content and reasoning/thinking content
// Claude format
if (parsed.delta?.text) {
totalContentLength += parsed.delta.text.length;
}
if (parsed.delta?.thinking) {
totalContentLength += parsed.delta.thinking.length;
}
// OpenAI format
if (parsed.choices?.[0]?.delta?.content) {
totalContentLength += parsed.choices[0].delta.content.length;
}
if (parsed.choices?.[0]?.delta?.reasoning_content) {
totalContentLength += parsed.choices[0].delta.reasoning_content.length;
}
// Gemini format - may have multiple parts
if (parsed.candidates?.[0]?.content?.parts) {
for (const part of parsed.candidates[0].content.parts) {
if (part.text && typeof part.text === "string") {
totalContentLength += part.text.length;
}
}
}
// Extract usage
const extracted = extractUsage(parsed);
if (extracted) state.usage = extracted; // Keep original usage for logging
// Translate: targetFormat -> openai -> sourceFormat
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
// Log OpenAI intermediate chunks (if available)
if (translated?._openaiIntermediate) {
for (const item of translated._openaiIntermediate) {
const openaiOutput = formatSSE(item, FORMATS.OPENAI);
reqLogger?.appendOpenAIChunk?.(openaiOutput);
}
}
if (translated?.length > 0) {
for (const item of translated) {
// Filter empty chunks
if (!hasValuableContent(item, sourceFormat)) {
continue; // Skip this empty chunk
}
// Inject estimated usage if finish chunk has no valid usage
const isFinishChunk =
item.type === "message_delta" || item.choices?.[0]?.finish_reason;
if (
state.finishReason &&
isFinishChunk &&
!hasValidUsage(item.usage) &&
totalContentLength > 0
) {
const estimated = estimateUsage(body, totalContentLength, sourceFormat);
item.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer
state.usage = estimated;
} else if (state.finishReason && isFinishChunk && state.usage) {
// Add buffer and filter usage for client (but keep original in state.usage for logging)
const buffered = addBufferToUsage(state.usage);
item.usage = filterUsageForFormat(buffered, sourceFormat);
}
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
}
}
},
flush(controller) {
// Clean up idle watchdog timer
if (idleTimer) {
clearInterval(idleTimer);
idleTimer = null;
}
if (streamTimedOut) {
return;
}
trackPendingRequest(model, provider, connectionId, false);
try {
const remaining = decoder.decode();
if (remaining) buffer += remaining;
if (mode === STREAM_MODE.PASSTHROUGH) {
if (buffer) {
let output = buffer;
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
output = "data: " + buffer.slice(5);
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
// Estimate usage if provider didn't return valid usage (PASSTHROUGH is always OpenAI format)
if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
}
if (hasValidUsage(usage)) {
logUsage(provider, usage, model, connectionId, apiKeyInfo);
} else {
appendRequestLog({
model,
provider,
connectionId,
tokens: null,
status: "200 OK",
}).catch(() => {});
}
// Notify caller for call log persistence
if (onComplete) {
try {
onComplete({ status: 200, usage });
} catch {}
}
return;
}
// Translate mode: process remaining buffer
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
// Log OpenAI intermediate chunks
if (translated?._openaiIntermediate) {
for (const item of translated._openaiIntermediate) {
const openaiOutput = formatSSE(item, FORMATS.OPENAI);
reqLogger?.appendOpenAIChunk?.(openaiOutput);
}
}
if (translated?.length > 0) {
for (const item of translated) {
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
}
}
}
// Flush remaining events (only once at stream end)
const flushed = translateResponse(targetFormat, sourceFormat, null, state);
// Log OpenAI intermediate chunks for flushed events
if (flushed?._openaiIntermediate) {
for (const item of flushed._openaiIntermediate) {
const openaiOutput = formatSSE(item, FORMATS.OPENAI);
reqLogger?.appendOpenAIChunk?.(openaiOutput);
}
}
if (flushed?.length > 0) {
for (const item of flushed) {
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
}
/**
* Usage injection strategy:
* Usage data (input/output tokens) is injected into the last content chunk
* or the finish_reason chunk rather than sent as a separate SSE event.
* This ensures all major clients (Claude CLI, Continue, Cursor) receive
* usage data even if they stop reading after the finish signal.
* The usage buffer (state.usage) accumulates across chunks and is only
* emitted once at stream end when merged into the final translated chunk.
*/
// Send [DONE] (only if not already sent during transform)
if (!doneSent) {
doneSent = true;
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
}
// Estimate usage if provider didn't return valid usage (for translate mode)
if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
}
if (hasValidUsage(state?.usage)) {
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKeyInfo);
} else {
appendRequestLog({
model,
provider,
connectionId,
tokens: null,
status: "200 OK",
}).catch(() => {});
}
// Notify caller for call log persistence
if (onComplete) {
try {
onComplete({ status: 200, usage: state?.usage });
} catch {}
}
} catch (error) {
console.log(`[STREAM] Error in flush (${model || "unknown"}):`, error.message || error);
}
},
},
// Writable side backpressure — limit buffered chunks to avoid unbounded memory
{ highWaterMark: 16 },
// Readable side backpressure — limit queued output chunks
{ highWaterMark: 16 }
);
}
// Convenience functions for backward compatibility
export function createSSETransformStreamWithLogger(
targetFormat,
sourceFormat,
provider = null,
reqLogger = null,
toolNameMap = null,
model = null,
connectionId = null,
body = null,
onComplete = null,
apiKeyInfo = null
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
targetFormat,
sourceFormat,
provider,
reqLogger,
toolNameMap,
model,
connectionId,
apiKeyInfo,
body,
onComplete,
});
}
export function createPassthroughStreamWithLogger(
provider = null,
reqLogger = null,
model = null,
connectionId = null,
body = null,
onComplete = null,
apiKeyInfo = null
) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
provider,
reqLogger,
model,
connectionId,
apiKeyInfo,
body,
onComplete,
});
}

View File

@@ -0,0 +1,137 @@
// Stream handler with disconnect detection - shared for all providers
// Get HH:MM:SS timestamp
function getTimeString() {
return new Date().toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/**
* Create stream controller with abort and disconnect detection
* @param {object} options
* @param {function} options.onDisconnect - Callback when client disconnects
* @param {object} options.log - Logger instance
* @param {string} options.provider - Provider name
* @param {string} options.model - Model name
*/
export function createStreamController({ onDisconnect, log, provider, model } = {}) {
const abortController = new AbortController();
const startTime = Date.now();
let disconnected = false;
let abortTimeout = null;
const logStream = (status) => {
const duration = Date.now() - startTime;
const p = provider?.toUpperCase() || "UNKNOWN";
console.log(
`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`
);
};
return {
signal: abortController.signal,
startTime,
isConnected: () => !disconnected,
// Call when client disconnects
handleDisconnect: (reason = "client_closed") => {
if (disconnected) return;
disconnected = true;
logStream(`disconnect: ${reason}`);
// Delay abort to allow cleanup
abortTimeout = setTimeout(() => {
abortController.abort();
}, 500);
onDisconnect?.({ reason, duration: Date.now() - startTime });
},
// Call when stream completes normally
handleComplete: () => {
if (disconnected) return;
disconnected = true;
logStream("complete");
if (abortTimeout) {
clearTimeout(abortTimeout);
abortTimeout = null;
}
},
// Call on error
handleError: (error) => {
if (abortTimeout) {
clearTimeout(abortTimeout);
abortTimeout = null;
}
if (error.name === "AbortError") {
logStream("aborted");
return;
}
logStream(`error: ${error.message}`);
},
abort: () => abortController.abort(),
};
}
/**
* Create transform stream with disconnect detection
* Wraps existing transform stream and adds abort capability
*/
export function createDisconnectAwareStream(transformStream, streamController) {
const reader = transformStream.readable.getReader();
const writer = transformStream.writable.getWriter();
return new ReadableStream({
async pull(controller) {
if (!streamController.isConnected()) {
controller.close();
return;
}
try {
const { done, value } = await reader.read();
if (done) {
streamController.handleComplete();
controller.close();
return;
}
controller.enqueue(value);
} catch (error) {
streamController.handleError(error);
controller.error(error);
}
},
cancel(reason) {
streamController.handleDisconnect(reason || "cancelled");
reader.cancel();
writer.abort();
},
});
}
/**
* Pipe provider response through transform with disconnect detection
* @param {Response} providerResponse - Response from provider
* @param {TransformStream} transformStream - Transform stream for SSE
* @param {object} streamController - Stream controller from createStreamController
*/
export function pipeWithDisconnect(providerResponse, transformStream, streamController) {
const transformedBody = providerResponse.body.pipeThrough(transformStream);
return createDisconnectAwareStream(
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => {} }) } },
streamController
);
}

View File

@@ -0,0 +1,123 @@
/**
* Stream helper utilities for SSE processing.
*
* Thinking Content representations (preserved through translation, not normalized):
* - Claude: `content_block_delta` with `delta.thinking` (string)
* - OpenAI: `choices[0].delta.reasoning_content` (string)
* - Gemini: `candidates[0].content.parts[].thought` (boolean flag + text)
*
* Each format's thinking field is mapped to the target format's equivalent
* during translation. No normalization is applied because each consumer
* expects its native format and normalization would lose format-specific metadata.
*/
import { FORMATS } from "../translator/formats.js";
// Parse SSE data line
export function parseSSELine(line) {
if (!line) return null;
// Trim leading whitespace before checking prefix character
const trimmed = line.trimStart();
if (!trimmed || trimmed.charCodeAt(0) !== 100) return null; // 'd' = 100
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return { done: true };
try {
return JSON.parse(data);
} catch (error) {
if (data.length > 0) {
console.log(
`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 200)}...`
);
}
return null;
}
}
// Check if chunk has valuable content (not empty)
export function hasValuableContent(chunk, format) {
// OpenAI format
if (format === FORMATS.OPENAI && chunk.choices?.[0]?.delta) {
const delta = chunk.choices[0].delta;
return (
(delta.content && delta.content !== "") ||
(delta.reasoning_content && delta.reasoning_content !== "") ||
(delta.tool_calls && delta.tool_calls.length > 0) ||
chunk.choices[0].finish_reason ||
delta.role
);
}
// Claude format
if (format === FORMATS.CLAUDE) {
const isContentBlockDelta = chunk.type === "content_block_delta";
const hasText = chunk.delta?.text && chunk.delta.text !== "";
const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== "";
const hasInputJson = chunk.delta?.partial_json && chunk.delta.partial_json !== "";
if (isContentBlockDelta && !hasText && !hasThinking && !hasInputJson) {
return false;
}
return true;
}
// Gemini format: filter chunks with no actual content parts
if (format === FORMATS.GEMINI && chunk.candidates?.[0]) {
const candidate = chunk.candidates[0];
// Keep chunks with finish reason or safety ratings (they signal completion)
if (candidate.finishReason) return true;
// Filter out chunks where parts array is empty or missing
const parts = candidate.content?.parts;
if (!parts || parts.length === 0) return false;
// Filter out chunks where all parts have empty text
const hasContent = parts.some(
(p) => (p.text && p.text !== "") || p.functionCall || p.executableCode
);
return hasContent;
}
return true; // Other formats: keep all chunks
}
// Fix invalid id (generic or too short)
export function fixInvalidId(parsed) {
if (parsed.id && (parsed.id === "chat" || parsed.id === "completion" || parsed.id.length < 8)) {
const fallbackId =
parsed.extend_fields?.requestId || parsed.extend_fields?.traceId || Date.now().toString(36);
parsed.id = `chatcmpl-${fallbackId}`;
return true;
}
return false;
}
// Remove null perf_metrics from usage (common across formats)
function cleanPerfMetrics(data) {
if (data?.usage && typeof data.usage === "object" && data.usage.perf_metrics === null) {
const { perf_metrics, ...usageWithoutPerf } = data.usage;
return { ...data, usage: usageWithoutPerf };
}
return data;
}
// Format output as SSE
export function formatSSE(data, sourceFormat) {
if (data === null || data === undefined) return "data: null\n\n";
if (data && data.done) return "data: [DONE]\n\n";
// OpenAI Responses API format
if (data && data.event && data.data) {
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
}
// Clean null perf_metrics before serialization
data = cleanPerfMetrics(data);
// Claude format
if (sourceFormat === FORMATS.CLAUDE && data && data.type) {
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
}
return `data: ${JSON.stringify(data)}\n\n`;
}

View File

@@ -0,0 +1,146 @@
/**
* Think Tag Parser
*
* Parses <think>...</think> tags from LLM output and converts them
* to a structured `reasoning_content` field.
*
* Used by providers like DeepSeek, Qwen, and iFlow that embed
* chain-of-thought reasoning inside <think> tags.
*
* Usage:
* import { extractThinkTags, hasThinkTags } from "./thinkTagParser.js";
*
* const { reasoning, content } = extractThinkTags(rawOutput);
* // reasoning = "step-by-step thinking..."
* // content = "final answer..."
*/
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
/**
* Check if text contains think tags
* @param {string} text - Raw output text
* @returns {boolean}
*/
export function hasThinkTags(text) {
if (!text) return false;
return text.includes(THINK_OPEN);
}
/**
* Extract think tags from text
* Returns the reasoning content (inside <think>) and the cleaned final content
*
* @param {string} text - Raw output text
* @returns {{ reasoning: string|null, content: string }}
*/
export function extractThinkTags(text) {
if (!text || !text.includes(THINK_OPEN)) {
return { reasoning: null, content: text || "" };
}
let reasoning = "";
let content = text;
let iterations = 0;
const maxIterations = 10; // safety limit
while (content.includes(THINK_OPEN) && iterations < maxIterations) {
const openIdx = content.indexOf(THINK_OPEN);
const closeIdx = content.indexOf(THINK_CLOSE, openIdx);
if (closeIdx === -1) {
// Unclosed think tag — treat everything after <think> as reasoning
reasoning += content.slice(openIdx + THINK_OPEN.length);
content = content.slice(0, openIdx);
break;
}
// Extract the think content
const thinkContent = content.slice(openIdx + THINK_OPEN.length, closeIdx);
reasoning += (reasoning ? "\n" : "") + thinkContent;
// Remove the think block from content
content = content.slice(0, openIdx) + content.slice(closeIdx + THINK_CLOSE.length);
iterations++;
}
return {
reasoning: reasoning.trim() || null,
content: content.trim(),
};
}
/**
* Process a streaming delta chunk and extract think content.
* Maintains state across chunks using a context object.
*
* @param {string} delta - New text chunk
* @param {object} ctx - Mutable context object { insideThink, buffer }
* @returns {{ reasoningDelta: string|null, contentDelta: string|null }}
*/
export function processStreamingThinkDelta(delta, ctx) {
if (!ctx.buffer) ctx.buffer = "";
ctx.buffer += delta;
let reasoningDelta = "";
let contentDelta = "";
while (ctx.buffer.length > 0) {
if (ctx.insideThink) {
// Looking for closing tag
const closeIdx = ctx.buffer.indexOf(THINK_CLOSE);
if (closeIdx === -1) {
// Might be a partial tag at the end — keep last few chars
if (ctx.buffer.length > THINK_CLOSE.length) {
const safe = ctx.buffer.slice(0, -(THINK_CLOSE.length - 1));
reasoningDelta += safe;
ctx.buffer = ctx.buffer.slice(-(THINK_CLOSE.length - 1));
}
break;
}
reasoningDelta += ctx.buffer.slice(0, closeIdx);
ctx.buffer = ctx.buffer.slice(closeIdx + THINK_CLOSE.length);
ctx.insideThink = false;
} else {
// Looking for opening tag
const openIdx = ctx.buffer.indexOf(THINK_OPEN);
if (openIdx === -1) {
// Might be a partial tag at the end
if (ctx.buffer.length > THINK_OPEN.length) {
const safe = ctx.buffer.slice(0, -(THINK_OPEN.length - 1));
contentDelta += safe;
ctx.buffer = ctx.buffer.slice(-(THINK_OPEN.length - 1));
}
break;
}
contentDelta += ctx.buffer.slice(0, openIdx);
ctx.buffer = ctx.buffer.slice(openIdx + THINK_OPEN.length);
ctx.insideThink = true;
}
}
return {
reasoningDelta: reasoningDelta || null,
contentDelta: contentDelta || null,
};
}
/**
* Flush remaining buffer content from streaming context.
* Call this when the stream ends.
*
* @param {object} ctx - Mutable context object { insideThink, buffer }
* @returns {{ reasoningDelta: string|null, contentDelta: string|null }}
*/
export function flushThinkBuffer(ctx) {
if (!ctx.buffer) return { reasoningDelta: null, contentDelta: null };
const remaining = ctx.buffer;
ctx.buffer = "";
if (ctx.insideThink) {
return { reasoningDelta: remaining || null, contentDelta: null };
}
return { reasoningDelta: null, contentDelta: remaining || null };
}

View File

@@ -0,0 +1,403 @@
/**
* Token Usage Tracking - Extract, normalize, estimate and log token usage
*/
import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb.js";
import { FORMATS } from "../translator/formats.js";
// ANSI color codes
export const COLORS = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
};
/**
* Safety buffer added to reported token usage to prevent clients from hitting
* context window limits. 2000 tokens accounts for overhead from system prompts,
* tool definitions, and format translation that may not be reflected in raw usage.
*/
const BUFFER_TOKENS = 2000;
// Get HH:MM:SS timestamp
function getTimeString() {
return new Date().toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/**
* Add buffer tokens to usage to prevent context errors
* @param {object} usage - Usage object (any format)
* @returns {object} Usage with buffer added
*/
export function addBufferToUsage(usage) {
if (!usage || typeof usage !== "object") return usage;
const result = { ...usage };
// Claude format
if (result.input_tokens !== undefined) {
result.input_tokens += BUFFER_TOKENS;
}
// OpenAI format
if (result.prompt_tokens !== undefined) {
result.prompt_tokens += BUFFER_TOKENS;
}
// Calculate or update total_tokens
if (result.total_tokens !== undefined) {
result.total_tokens += BUFFER_TOKENS;
} else if (result.prompt_tokens !== undefined && result.completion_tokens !== undefined) {
// Calculate total_tokens if not exists
result.total_tokens = result.prompt_tokens + result.completion_tokens;
}
return result;
}
export function filterUsageForFormat(usage, targetFormat) {
if (!usage || typeof usage !== "object") return usage;
// Helper to pick only defined fields from usage
const pickFields = (fields) => {
const filtered = {};
for (const field of fields) {
if (usage[field] !== undefined) {
filtered[field] = usage[field];
}
}
return filtered;
};
// Define allowed fields for each format
const formatFields = {
[FORMATS.CLAUDE]: [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"estimated",
],
[FORMATS.GEMINI]: [
"promptTokenCount",
"candidatesTokenCount",
"totalTokenCount",
"cachedContentTokenCount",
"thoughtsTokenCount",
"estimated",
],
[FORMATS.OPENAI_RESPONSES]: [
"input_tokens",
"output_tokens",
"input_tokens_details",
"output_tokens_details",
"estimated",
],
// OpenAI format (default for OPENAI, CODEX, KIRO, etc.)
default: [
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cached_tokens",
"reasoning_tokens",
"prompt_tokens_details",
"completion_tokens_details",
"estimated",
],
};
// Get fields for target format
let fields = formatFields[targetFormat];
// Use same fields for similar formats
if (targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.ANTIGRAVITY) {
fields = formatFields[FORMATS.GEMINI];
} else if (targetFormat === FORMATS.OPENAI_RESPONSE) {
fields = formatFields[FORMATS.OPENAI_RESPONSES];
} else if (!fields) {
fields = formatFields.default;
}
return pickFields(fields);
}
/**
* Normalize usage object - ensure all values are valid numbers
*/
export function normalizeUsage(usage) {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
const normalized = {};
const assignNumber = (key, value) => {
if (value === undefined || value === null) return;
const numeric = Number(value);
if (Number.isFinite(numeric)) normalized[key] = numeric;
};
assignNumber("prompt_tokens", usage?.prompt_tokens);
assignNumber("completion_tokens", usage?.completion_tokens);
assignNumber("total_tokens", usage?.total_tokens);
assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens);
assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens);
assignNumber("cached_tokens", usage?.cached_tokens);
assignNumber("reasoning_tokens", usage?.reasoning_tokens);
if (Object.keys(normalized).length === 0) return null;
return normalized;
}
/**
* Check if usage has valid token data
* Valid = has at least one token field with value > 0
* Invalid = empty object {}, null, undefined, no token fields, or all zeros
*/
export function hasValidUsage(usage) {
if (!usage || typeof usage !== "object") return false;
// Check for any known token field with value > 0
const tokenFields = [
"prompt_tokens",
"completion_tokens",
"total_tokens", // OpenAI
"input_tokens",
"output_tokens", // Claude
"promptTokenCount",
"candidatesTokenCount", // Gemini
];
for (const field of tokenFields) {
if (typeof usage[field] === "number" && usage[field] > 0) {
return true;
}
}
return false;
}
/**
* Extract usage from any format (Claude, OpenAI, Gemini, Responses API)
*/
export function extractUsage(chunk) {
if (!chunk || typeof chunk !== "object") return null;
// Claude format (message_delta event)
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
return normalizeUsage({
prompt_tokens: chunk.usage.input_tokens || 0,
completion_tokens: chunk.usage.output_tokens || 0,
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens,
});
}
// OpenAI Responses API format (response.completed or response.done)
if (
(chunk.type === "response.completed" || chunk.type === "response.done") &&
chunk.response?.usage &&
typeof chunk.response.usage === "object"
) {
const usage = chunk.response.usage;
return normalizeUsage({
prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0,
completion_tokens: usage.output_tokens || usage.completion_tokens || 0,
cached_tokens: usage.input_tokens_details?.cached_tokens,
reasoning_tokens: usage.output_tokens_details?.reasoning_tokens,
});
}
// OpenAI format
if (chunk.usage && typeof chunk.usage === "object" && chunk.usage.prompt_tokens !== undefined) {
return normalizeUsage({
prompt_tokens: chunk.usage.prompt_tokens,
completion_tokens: chunk.usage.completion_tokens || 0,
cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens,
reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens,
});
}
// Gemini format (Antigravity)
if (chunk.usageMetadata && typeof chunk.usageMetadata === "object") {
return normalizeUsage({
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount,
cached_tokens: chunk.usageMetadata?.cachedContentTokenCount,
reasoning_tokens: chunk.usageMetadata?.thoughtsTokenCount,
});
}
return null;
}
// Heuristic token estimation constants
const CHARS_PER_TOKEN_SCHEMA = 6; // ~6 chars/token for JSON schemas (more verbose per token)
/**
* Improved token estimation heuristic (no dependency).
* Splits text on common token boundaries (whitespace, punctuation, camelCase)
* and applies a sub-word correction factor. Better accuracy for:
* - English text (~4 chars/token)
* - CJK text (~1 char/token for ideographs)
* - Code (~3.5 chars/token, more punctuation-heavy)
*
* @param {string} text - Text to estimate tokens for
* @returns {number} Estimated token count
*/
function estimateTokenCount(text) {
if (!text || typeof text !== "string") return 0;
// Count CJK ideographs separately — each is roughly 1 token
const cjkMatches = text.match(/[\u3000-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}]/gu);
const cjkCount = cjkMatches ? cjkMatches.length : 0;
// Remove CJK chars for the remaining estimation
const nonCJK = text.replace(/[\u3000-\u9fff\uf900-\ufaff]/g, " ");
// Split on token boundaries: whitespace, punctuation, camelCase transitions
const tokens = nonCJK
.split(/(\s+|[^\w\s]|(?<=[a-z])(?=[A-Z]))/)
.filter((t) => t && t.trim().length > 0);
// Apply sub-word correction: BPE tokenizers often split long words
// into sub-word pieces, so raw token count underestimates slightly
const estimatedNonCJK = Math.ceil(tokens.length * 1.3);
return cjkCount + estimatedNonCJK;
}
/**
* Estimate input tokens from request body.
* Separates tool definitions (JSON schemas) from message content
* for more accurate estimation since JSON schemas are more verbose but
* compress into fewer tokens than plain text.
*/
export function estimateInputTokens(body) {
if (!body || typeof body !== "object") return 0;
try {
let toolTokens = 0;
let messageTokens = 0;
// Separate tool definitions from the rest of the body
if (body.tools && Array.isArray(body.tools)) {
const toolStr = JSON.stringify(body.tools);
toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA);
// Estimate messages without tools
const { tools, ...bodyWithoutTools } = body;
messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools));
} else {
messageTokens = estimateTokenCount(JSON.stringify(body));
}
return messageTokens + toolTokens;
} catch (err) {
// Fallback if stringify fails
return 0;
}
}
/**
* Estimate output tokens from content length.
* Uses improved heuristic when possible, falls back to length-based estimation.
*/
export function estimateOutputTokens(contentLength) {
if (!contentLength || contentLength <= 0) return 0;
// When we only have a character count, use 4 chars/token with sub-word correction
return Math.max(1, Math.ceil(contentLength / 3.5));
}
/**
* Format usage object based on target format
* @param {number} inputTokens - Input/prompt tokens
* @param {number} outputTokens - Output/completion tokens
* @param {string} targetFormat - Target format from FORMATS
*/
export function formatUsage(inputTokens, outputTokens, targetFormat) {
// Claude format uses input_tokens/output_tokens
if (targetFormat === FORMATS.CLAUDE) {
return addBufferToUsage({
input_tokens: inputTokens,
output_tokens: outputTokens,
estimated: true,
});
}
// Default: OpenAI format (works for openai, gemini, responses, etc.)
return addBufferToUsage({
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
estimated: true,
});
}
/**
* Estimate full usage when provider doesn't return it
* @param {object} body - Request body for input token estimation
* @param {number} contentLength - Content length for output token estimation
* @param {string} targetFormat - Target format from FORMATS constant
*/
export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) {
return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat);
}
/**
* Log usage with cache info (green color)
*/
export function logUsage(provider, usage, model = null, connectionId = null, apiKeyInfo = null) {
if (!usage || typeof usage !== "object") return;
const p = provider?.toUpperCase() || "UNKNOWN";
// Support both formats:
// - OpenAI: prompt_tokens, completion_tokens
// - Claude: input_tokens, output_tokens
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";
let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`;
// Add estimated flag if present
if (usage.estimated) {
msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
}
// Add cache info if present (unified from different formats)
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens;
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
const cacheCreation = usage.cache_creation_input_tokens;
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
const reasoning = usage.reasoning_tokens;
if (reasoning) msg += ` | reasoning=${reasoning}`;
console.log(msg);
// Save to usage DB
const tokens = {
input: inTokens,
output: outTokens,
cacheRead: cacheRead || 0,
cacheCreation: cacheCreation || 0,
reasoning: reasoning || 0,
};
saveRequestUsage({
model,
provider,
connectionId,
apiKeyId: apiKeyInfo?.id || undefined,
apiKeyName: apiKeyInfo?.name || undefined,
tokens,
}).catch(() => {});
appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => {});
}

Some files were not shown because too many files have changed in this diff Show More