mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
54
.agent/workflows/git-workflow.md
Normal file
54
.agent/workflows/git-workflow.md
Normal 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
32
.dockerignore
Normal 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
97
.env.example
Normal 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
27
.github/dependabot.yml
vendored
Normal 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
22
.github/workflows/codex-review.yml
vendored
Normal 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
83
.gitignore
vendored
Normal 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
60
.npmignore
Normal file
60
.npmignore
Normal 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
20
.vscode/settings.json
vendored
Normal 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
105
AGENTS.md
Normal 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
193
CHANGELOG.md
Normal 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
46
Dockerfile
Normal 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
|
||||
|
||||
47
docker-compose.prod.yml
Normal file
47
docker-compose.prod.yml
Normal 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
101
docker-compose.yml
Normal 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
666
docs/ARCHITECTURE.md
Normal 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`
|
||||
574
docs/CODEBASE_DOCUMENTATION.md
Normal file
574
docs/CODEBASE_DOCUMENTATION.md
Normal 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
16
eslint.config.mjs
Normal 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
BIN
images/omniroute.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
61
next.config.mjs
Normal file
61
next.config.mjs
Normal 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
8
open-sse/.npmignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
test/
|
||||
*.test.js
|
||||
.env
|
||||
.env.*
|
||||
|
||||
120
open-sse/config/codexInstructions.js
Normal file
120
open-sse/config/codexInstructions.js
Normal 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 final‑answer 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; 4–6 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; self‑contained; 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, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Optionally include line/column (1‑based): :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`;
|
||||
130
open-sse/config/constants.js
Normal file
130
open-sse/config/constants.js
Normal 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:"];
|
||||
102
open-sse/config/credentialLoader.js
Normal file
102
open-sse/config/credentialLoader.js
Normal 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;
|
||||
}
|
||||
8
open-sse/config/defaultThinkingSignature.js
Normal file
8
open-sse/config/defaultThinkingSignature.js
Normal 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 = "...";
|
||||
126
open-sse/config/embeddingRegistry.js
Normal file
126
open-sse/config/embeddingRegistry.js
Normal 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;
|
||||
}
|
||||
132
open-sse/config/imageRegistry.js
Normal file
132
open-sse/config/imageRegistry.js
Normal 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;
|
||||
}
|
||||
28
open-sse/config/ollamaModels.js
Normal file
28
open-sse/config/ollamaModels.js
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
43
open-sse/config/providerModels.js
Normal file
43
open-sse/config/providerModels.js
Normal 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] || [];
|
||||
}
|
||||
831
open-sse/config/providerRegistry.js
Normal file
831
open-sse/config/providerRegistry.js
Normal 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);
|
||||
}
|
||||
273
open-sse/executors/antigravity.js
Normal file
273
open-sse/executors/antigravity.js
Normal 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
133
open-sse/executors/base.js
Normal 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;
|
||||
75
open-sse/executors/codex.js
Normal file
75
open-sse/executors/codex.js
Normal 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;
|
||||
}
|
||||
}
|
||||
739
open-sse/executors/cursor.js
Normal file
739
open-sse/executors/cursor.js
Normal 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;
|
||||
107
open-sse/executors/default.js
Normal file
107
open-sse/executors/default.js
Normal 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;
|
||||
65
open-sse/executors/gemini-cli.js
Normal file
65
open-sse/executors/gemini-cli.js
Normal 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;
|
||||
139
open-sse/executors/github.js
Normal file
139
open-sse/executors/github.js
Normal 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;
|
||||
38
open-sse/executors/index.js
Normal file
38
open-sse/executors/index.js
Normal 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
511
open-sse/executors/kiro.js
Normal 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;
|
||||
567
open-sse/handlers/chatCore.js
Normal file
567
open-sse/handlers/chatCore.js
Normal 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;
|
||||
}
|
||||
171
open-sse/handlers/embeddings.js
Normal file
171
open-sse/handlers/embeddings.js
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
341
open-sse/handlers/imageGeneration.js
Normal file
341
open-sse/handlers/imageGeneration.js
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
290
open-sse/handlers/responseTranslator.js
Normal file
290
open-sse/handlers/responseTranslator.js
Normal 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;
|
||||
}
|
||||
79
open-sse/handlers/responsesHandler.js
Normal file
79
open-sse/handlers/responsesHandler.js
Normal 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": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
125
open-sse/handlers/sseParser.js
Normal file
125
open-sse/handlers/sseParser.js
Normal 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 || {},
|
||||
};
|
||||
}
|
||||
64
open-sse/handlers/usageExtractor.js
Normal file
64
open-sse/handlers/usageExtractor.js
Normal 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
110
open-sse/index.js
Normal 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
13
open-sse/package.json
Normal 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",
|
||||
"./*": "./*"
|
||||
}
|
||||
}
|
||||
201
open-sse/services/accountFallback.js
Normal file
201
open-sse/services/accountFallback.js
Normal 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
568
open-sse/services/combo.js
Normal 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" },
|
||||
});
|
||||
}
|
||||
52
open-sse/services/comboConfig.js
Normal file
52
open-sse/services/comboConfig.js
Normal 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 };
|
||||
}
|
||||
132
open-sse/services/comboMetrics.js
Normal file
132
open-sse/services/comboMetrics.js
Normal 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
188
open-sse/services/model.js
Normal 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,
|
||||
};
|
||||
}
|
||||
297
open-sse/services/provider.js
Normal file
297
open-sse/services/provider.js
Normal 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;
|
||||
}
|
||||
327
open-sse/services/rateLimitManager.js
Normal file
327
open-sse/services/rateLimitManager.js
Normal 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;
|
||||
}
|
||||
179
open-sse/services/rateLimitSemaphore.js
Normal file
179
open-sse/services/rateLimitSemaphore.js
Normal 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();
|
||||
}
|
||||
809
open-sse/services/tokenRefresh.js
Normal file
809
open-sse/services/tokenRefresh.js
Normal 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
647
open-sse/services/usage.js
Normal 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." };
|
||||
}
|
||||
}
|
||||
459
open-sse/transformer/responsesTransformer.js
Normal file
459
open-sse/transformer/responsesTransformer.js
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
13
open-sse/translator/formats.js
Normal file
13
open-sse/translator/formats.js
Normal 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",
|
||||
};
|
||||
191
open-sse/translator/helpers/claudeHelper.js
Normal file
191
open-sse/translator/helpers/claudeHelper.js
Normal 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;
|
||||
}
|
||||
371
open-sse/translator/helpers/geminiHelper.js
Normal file
371
open-sse/translator/helpers/geminiHelper.js
Normal 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;
|
||||
}
|
||||
20
open-sse/translator/helpers/maxTokensHelper.js
Normal file
20
open-sse/translator/helpers/maxTokensHelper.js
Normal 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;
|
||||
}
|
||||
143
open-sse/translator/helpers/openaiHelper.js
Normal file
143
open-sse/translator/helpers/openaiHelper.js
Normal 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;
|
||||
}
|
||||
104
open-sse/translator/helpers/responsesApiHelper.js
Normal file
104
open-sse/translator/helpers/responsesApiHelper.js
Normal 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;
|
||||
}
|
||||
110
open-sse/translator/helpers/toolCallHelper.js
Normal file
110
open-sse/translator/helpers/toolCallHelper.js
Normal 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;
|
||||
}
|
||||
292
open-sse/translator/index.js
Normal file
292
open-sse/translator/index.js
Normal 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() {}
|
||||
231
open-sse/translator/request/antigravity-to-openai.js
Normal file
231
open-sse/translator/request/antigravity-to-openai.js
Normal 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);
|
||||
173
open-sse/translator/request/claude-to-gemini.js
Normal file
173
open-sse/translator/request/claude-to-gemini.js
Normal 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);
|
||||
232
open-sse/translator/request/claude-to-openai.js
Normal file
232
open-sse/translator/request/claude-to-openai.js
Normal 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);
|
||||
148
open-sse/translator/request/gemini-to-openai.js
Normal file
148
open-sse/translator/request/gemini-to-openai.js
Normal 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);
|
||||
307
open-sse/translator/request/openai-responses.js
Normal file
307
open-sse/translator/request/openai-responses.js
Normal 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);
|
||||
353
open-sse/translator/request/openai-to-claude.js
Normal file
353
open-sse/translator/request/openai-to-claude.js
Normal 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);
|
||||
115
open-sse/translator/request/openai-to-cursor.js
Normal file
115
open-sse/translator/request/openai-to-cursor.js
Normal 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);
|
||||
424
open-sse/translator/request/openai-to-gemini.js
Normal file
424
open-sse/translator/request/openai-to-gemini.js
Normal 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);
|
||||
290
open-sse/translator/request/openai-to-kiro.js
Normal file
290
open-sse/translator/request/openai-to-kiro.js
Normal 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);
|
||||
242
open-sse/translator/response/claude-to-openai.js
Normal file
242
open-sse/translator/response/claude-to-openai.js
Normal 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);
|
||||
30
open-sse/translator/response/cursor-to-openai.js
Normal file
30
open-sse/translator/response/cursor-to-openai.js
Normal 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);
|
||||
182
open-sse/translator/response/gemini-to-claude.js
Normal file
182
open-sse/translator/response/gemini-to-claude.js
Normal 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);
|
||||
258
open-sse/translator/response/gemini-to-openai.js
Normal file
258
open-sse/translator/response/gemini-to-openai.js
Normal 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);
|
||||
198
open-sse/translator/response/kiro-to-openai.js
Normal file
198
open-sse/translator/response/kiro-to-openai.js
Normal 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);
|
||||
558
open-sse/translator/response/openai-responses.js
Normal file
558
open-sse/translator/response/openai-responses.js
Normal 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);
|
||||
124
open-sse/translator/response/openai-to-antigravity.js
Normal file
124
open-sse/translator/response/openai-to-antigravity.js
Normal 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);
|
||||
231
open-sse/translator/response/openai-to-claude.js
Normal file
231
open-sse/translator/response/openai-to-claude.js
Normal 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
137
open-sse/types.d.ts
vendored
Normal 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;
|
||||
}
|
||||
289
open-sse/utils/bypassHandler.js
Normal file
289
open-sse/utils/bypassHandler.js
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
136
open-sse/utils/cursorChecksum.js
Normal file
136
open-sse/utils/cursorChecksum.js
Normal 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;
|
||||
681
open-sse/utils/cursorProtobuf.js
Normal file
681
open-sse/utils/cursorProtobuf.js
Normal 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
185
open-sse/utils/error.js
Normal 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
167
open-sse/utils/logger.js
Normal 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;
|
||||
74
open-sse/utils/networkProxy.js
Normal file
74
open-sse/utils/networkProxy.js
Normal 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;
|
||||
}
|
||||
90
open-sse/utils/ollamaTransform.js
Normal file
90
open-sse/utils/ollamaTransform.js
Normal 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": "*" },
|
||||
});
|
||||
}
|
||||
129
open-sse/utils/proxyDispatcher.js
Normal file
129
open-sse/utils/proxyDispatcher.js
Normal 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;
|
||||
}
|
||||
156
open-sse/utils/proxyFetch.js
Normal file
156
open-sse/utils/proxyFetch.js
Normal 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;
|
||||
251
open-sse/utils/requestLogger.js
Normal file
251
open-sse/utils/requestLogger.js
Normal 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
465
open-sse/utils/stream.js
Normal 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,
|
||||
});
|
||||
}
|
||||
137
open-sse/utils/streamHandler.js
Normal file
137
open-sse/utils/streamHandler.js
Normal 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
|
||||
);
|
||||
}
|
||||
123
open-sse/utils/streamHelpers.js
Normal file
123
open-sse/utils/streamHelpers.js
Normal 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`;
|
||||
}
|
||||
146
open-sse/utils/thinkTagParser.js
Normal file
146
open-sse/utils/thinkTagParser.js
Normal 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 };
|
||||
}
|
||||
403
open-sse/utils/usageTracking.js
Normal file
403
open-sse/utils/usageTracking.js
Normal 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
Reference in New Issue
Block a user