commit 699329944e73a3501a989229ade531cdff45ee5d Author: diegosouzapw Date: Fri Feb 13 16:26:43 2026 -0300 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. diff --git a/.agent/workflows/git-workflow.md b/.agent/workflows/git-workflow.md new file mode 100644 index 0000000000..5d718a7678 --- /dev/null +++ b/.agent/workflows/git-workflow.md @@ -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/ + ``` + +2. **During development**, commit to the feature branch: + + ```bash + git add -A && git commit -m "(): " + ``` + +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/ + ``` + +5. **DO NOT** create a PR, merge, or push to `main`. Let the user handle that. + +## Branch naming convention + +- `feature/` — new features +- `fix/` — bugfixes +- `refactor/` — refactoring +- `docker/` — Docker / infrastructure changes +- `style/` — UI / CSS changes + +## Commit types + +- `feat` — new feature +- `fix` — bugfix +- `refactor` — code refactoring +- `style` — UI / CSS changes +- `docker` — Docker / infrastructure +- `docs` — documentation +- `chore` — maintenance diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..5b921cd983 --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..19336b3ca0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..f91006e1a1 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/codex-review.yml b/.github/workflows/codex-review.yml new file mode 100644 index 0000000000..6bc13f5c60 --- /dev/null +++ b/.github/workflows/codex-review.yml @@ -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' + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..66fae56790 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..2312dc587f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..9bec212d3d --- /dev/null +++ b/.npmignore @@ -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/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..8e3d0f161b --- /dev/null +++ b/.vscode/settings.json @@ -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" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..c5c2e48254 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..f11ad6da83 --- /dev/null +++ b/CHANGELOG.md @@ -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/`, 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 `` 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..b49318a03e --- /dev/null +++ b/Dockerfile @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000000..b4c091a67e --- /dev/null +++ b/README.md @@ -0,0 +1,1282 @@ +
+ OmniRoute Dashboard + + # OmniRoute - Free AI Router + + **Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.** + + **28 Providers • Embeddings • Image Generation • Think Tag Parsing** + + **Free AI Provider for OpenClaw.** + +

+ OpenClaw +

+ + > *This project is inspired by and originally forked from [9router](https://github.com/decolua/9router) by [decolua](https://github.com/decolua). Thank you for the incredible foundation!* + + [![License](https://img.shields.io/github/license/diegosouzapw/OmniRoute)](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE) + + [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) +
+ +--- + +## 🤔 Why OmniRoute? + +**Stop wasting money and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Expensive APIs ($20-50/month per provider) +- ❌ Manual switching between providers + +**OmniRoute solves this:** + +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with Claude Code, Codex, Gemini CLI, Cursor, Cline, any CLI tool + +--- + +## 🔄 How It Works + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────┐ +│ OmniRoute (Smart Router) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking + Embeddings + Images │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI + │ ↓ quota exhausted + ├─→ [Tier 2: API KEY] DeepSeek, Groq, xAI, Mistral, Together, etc. + │ ↓ budget limit + ├─→ [Tier 3: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 4: FREE] iFlow, Qwen, Kiro (unlimited) + +Result: Never stop coding, minimal cost +``` + +--- + +## ⚡ Quick Start + +**1. Install globally:** + +```bash +npm install -g omniroute +omniroute +``` + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Claude Code** or **Antigravity** → OAuth login → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: if/kimi-k2-thinking +``` + +**That's it!** Start coding with FREE AI models. + +**Alternative: run from source (this repository):** + +This repository package is private (`omniroute-app`), so source/Docker execution is the expected local development path. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Production mode: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +Default URLs: + +- Dashboard: `http://localhost:20128/dashboard` +- OpenAI-compatible API: `http://localhost:20128/v1` + +--- + +## 💡 Key Features + +| Feature | What It Does | Why It Matters | +| -------------------------------- | ------------------------------------------ | ----------------------------------- | +| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | +| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini seamless | Works with any CLI tool | +| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | +| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | +| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | +| 🧩 **Custom Models** | Add any model ID to any provider | No app update needed for new models | +| 🛣️ **Dedicated Provider Routes** | Per-provider API endpoints | Direct routing, model validation | +| 🌐 **Network Proxy** | Hierarchical outbound proxy + env fallback | Works behind firewalls/VPNs | +| 📋 **Model Catalog API** | All models grouped by provider + type | Discover available models easily | +| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | +| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | +| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | +| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | + +
+📖 Feature Details + +### 🎯 Smart 3-Tier Fallback + +Create combos with automatic fallback: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (your subscription) + 2. glm/glm-4.7 (cheap backup, $0.6/1M) + 3. if/kimi-k2-thinking (free fallback) + +→ Auto switches when quota runs out or errors occur +``` + +### 📊 Real-Time Quota Tracking + +- Token consumption per provider +- Reset countdown (5-hour, daily, weekly) +- Cost estimation for paid tiers +- Monthly spending reports + +### 🔄 Format Translation + +Seamless translation between formats: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Your CLI tool sends OpenAI format → OmniRoute translates → Provider receives native format +- Works with any tool that supports custom OpenAI endpoints + +### 👥 Multi-Account Support + +- Add multiple accounts per provider +- Auto round-robin or priority-based routing +- Fallback to next account when one hits quota + +### 🔄 Auto Token Refresh + +- OAuth tokens automatically refresh before expiration +- No manual re-authentication needed +- Seamless experience across all providers + +### 🎨 Custom Combos + +- Create unlimited model combinations +- Mix subscription, cheap, and free tiers +- Name your combos for easy access +- Share combos across devices with Cloud Sync + +### 📝 Request Logging + +- Enable debug mode for full request/response logs +- Track API calls, headers, and payloads +- Troubleshoot integration issues +- Export logs for analysis + +### 💾 Cloud Sync + +- Sync providers, combos, and settings across devices +- Automatic background sync +- Secure encrypted storage +- Access your setup from anywhere + +#### Cloud Runtime Notes + +- Prefer server-side cloud variables in production: + - `BASE_URL` (internal callback URL used by sync scheduler) + - `CLOUD_URL` (cloud sync endpoint base) +- `NEXT_PUBLIC_BASE_URL` and `NEXT_PUBLIC_CLOUD_URL` are still supported for compatibility/UI, but server runtime now prioritizes `BASE_URL`/`CLOUD_URL`. +- Cloud sync requests now use timeout + fail-fast behavior to avoid UI hanging when cloud DNS/network is unavailable. + +### 📊 Usage Analytics + +- Track token usage per provider and model +- Cost estimation and spending trends +- Monthly reports and insights +- Optimize your AI spending + +### 🌐 Deploy Anywhere + +- 💻 **Localhost** - Default, works offline +- ☁️ **VPS/Cloud** - Share across devices +- 🐳 **Docker** - One-command deployment +- 🚀 **Cloudflare Workers** - Global edge network + +
+ +--- + +## 💰 Pricing at a Glance + +| Tier | Provider | Cost | Quota Reset | Best For | +| ------------------- | ----------------- | ----------- | ---------------- | -------------------- | +| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed | +| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | +| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! | +| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | +| **🔑 API KEY** | DeepSeek | Pay per use | None | Cheap reasoning | +| | Groq | Pay per use | None | Ultra-fast inference | +| | xAI (Grok) | Pay per use | None | Grok 4 reasoning | +| | Mistral | Pay per use | None | EU-hosted models | +| | Perplexity | Pay per use | None | Search-augmented | +| | Together AI | Pay per use | None | Open-source models | +| | Fireworks AI | Pay per use | None | Fast FLUX images | +| | Cerebras | Pay per use | None | Wafer-scale speed | +| | Cohere | Pay per use | None | Command R+ RAG | +| | NVIDIA NIM | Pay per use | None | Enterprise models | +| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost | +| **🆓 FREE** | iFlow | $0 | Unlimited | 8 models free | +| | Qwen | $0 | Unlimited | 3 models free | +| | Kiro | $0 | Unlimited | Claude free | + +**💡 Pro Tip:** Start with Gemini CLI (180K free/month) + iFlow (unlimited free) combo = $0 cost! + +--- + +## 🎯 Use Cases + +### Case 1: "I have Claude Pro subscription" + +**Problem:** Quota expires unused, rate limits during heavy coding + +**Solution:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (use subscription fully) + 2. glm/glm-4.7 (cheap backup when quota out) + 3. if/kimi-k2-thinking (free emergency fallback) + +Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total +vs. $20 + hitting limits = frustration +``` + +### Case 2: "I want zero cost" + +**Problem:** Can't afford subscriptions, need reliable AI coding + +**Solution:** + +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180K free/month) + 2. if/kimi-k2-thinking (unlimited free) + 3. qw/qwen3-coder-plus (unlimited free) + +Monthly cost: $0 +Quality: Production-ready models +``` + +### Case 3: "I need 24/7 coding, no interruptions" + +**Problem:** Deadlines, can't afford downtime + +**Solution:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (best quality) + 2. cx/gpt-5.2-codex (second subscription) + 3. glm/glm-4.7 (cheap, resets daily) + 4. minimax/MiniMax-M2.1 (cheapest, 5h reset) + 5. if/kimi-k2-thinking (free unlimited) + +Result: 5 layers of fallback = zero downtime +Monthly cost: $20-200 (subscriptions) + $10-20 (backup) +``` + +### Case 4: "I want FREE AI in OpenClaw" + +**Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free + +**Solution:** + +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (unlimited free) + 2. if/minimax-m2.1 (unlimited free) + 3. if/kimi-k2-thinking (unlimited free) + +Monthly cost: $0 +Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## 📖 Setup Guide + +
+🔐 Subscription Providers (Maximize Value) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → Connect Claude Code +→ OAuth login → Auto token refresh +→ 5-hour + weekly quota tracking + +Models: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → Connect Codex +→ OAuth login (port 1455) +→ 5-hour + weekly reset + +Models: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (FREE 180K/month!) + +```bash +Dashboard → Providers → Connect Gemini CLI +→ Google OAuth +→ 180K completions/month + 1K/day + +Models: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Best Value:** Huge free tier! Use this before paid tiers. + +### GitHub Copilot + +```bash +Dashboard → Providers → Connect GitHub +→ OAuth via GitHub +→ Monthly reset (1st of month) + +Models: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Cheap Providers (Backup) + +### GLM-4.7 (Daily reset, $0.6/1M) + +1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) +2. Get API key from Coding Plan +3. Dashboard → Add API Key: + - Provider: `glm` + - API Key: `your-key` + +**Use:** `glm/glm-4.7` + +**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. + +### MiniMax M2.1 (5h reset, $0.20/1M) + +1. Sign up: [MiniMax](https://www.minimax.io/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `minimax/MiniMax-M2.1` + +**Pro Tip:** Cheapest option for long context (1M tokens)! + +### Kimi K2 ($9/month flat) + +1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `kimi/kimi-latest` + +**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! + +
+ +
+🆓 FREE Providers (Emergency Backup) + +### iFlow (8 FREE models) + +```bash +Dashboard → Connect iFlow +→ iFlow OAuth login +→ Unlimited usage + +Models: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 FREE models) + +```bash +Dashboard → Connect Qwen +→ Device code authorization +→ Unlimited usage + +Models: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (Claude FREE) + +```bash +Dashboard → Connect Kiro +→ AWS Builder ID or Google/GitHub +→ Unlimited usage + +Models: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Create Combos + +### Example 1: Maximize Subscription → Cheap Backup + +``` +Dashboard → Combos → Create New + +Name: premium-coding +Models: + 1. cc/claude-opus-4-6 (Subscription primary) + 2. glm/glm-4.7 (Cheap backup, $0.6/1M) + 3. minimax/MiniMax-M2.1 (Cheapest fallback, $0.20/1M) + +Use in CLI: premium-coding + +Monthly cost example (100M tokens): + 80M via Claude (subscription): $0 extra + 15M via GLM: $9 + 5M via MiniMax: $1 + Total: $10 + your subscription +``` + +### Example 2: Free-Only (Zero Cost) + +``` +Name: free-combo +Models: + 1. gc/gemini-3-flash-preview (180K free/month) + 2. if/kimi-k2-thinking (unlimited) + 3. qw/qwen3-coder-plus (unlimited) + +Cost: $0 forever! +``` + +
+ +
+🔧 CLI Integration + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [from omniroute dashboard] + Model: cc/claude-opus-4-6 +``` + +Or use combo: `premium-coding` + +### Claude Code + +Edit `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-omniroute-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-omniroute-api-key" + +codex "your prompt" +``` + +### OpenClaw + +Edit `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "omniroute/if/glm-4.7" + } + } + }, + "models": { + "providers": { + "omniroute": { + "baseUrl": "http://localhost:20128/v1", + "apiKey": "your-omniroute-api-key", + "api": "openai-completions", + "models": [ + { + "id": "if/glm-4.7", + "name": "glm-4.7" + } + ] + } + } + } +} +``` + +**Or use Dashboard:** CLI Tools → OpenClaw → Auto-config + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [from dashboard] +Model: cc/claude-opus-4-6 +``` + +
+ +
+🚀 Deployment + +### VPS Deployment + +```bash +# Clone and install +git clone https://github.com/diegosouzapw/OmniRoute.git +cd OmniRoute +npm install +npm run build + +# Configure +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/omniroute" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://omniroute.dev" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Start +npm run start + +# Or use PM2 +npm install -g pm2 +pm2 start npm --name omniroute -- start +pm2 save +pm2 startup +``` + +### Docker + +```bash +# Build image (default target = runner-cli with codex/claude/droid preinstalled) +docker build -t omniroute:cli . + +# Build minimal image without bundled CLIs +docker build --target runner-base -t omniroute:base . + +# Build explicit CLI profile +docker build --target runner-cli -t omniroute:cli . +``` + +Portable mode (recommended for Docker Desktop and generic hosts): + +```bash +docker run -d \ + --name omniroute \ + -p 20128:20128 \ + --env-file ./.env \ + -v omniroute-data:/app/data \ + omniroute:cli +``` + +Host-integrated mode (Linux-first; use host mounts for CLI bins/configs): + +Host prerequisites example (real host binaries): + +```bash +# Install host CLIs (example) +npm install -g @openai/codex @anthropic-ai/claude-code droid openclaw@latest cline @continuedev/cli +``` + +Run using host-mounted CLIs (`codex` from host tool dir + npm global CLIs from Node root): + +```bash +docker run -d \ + --name omniroute \ + -p 20128:20128 \ + --env-file ./.env \ + -e CLI_MODE=host \ + -e CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin:/host-codex \ + -e CLI_CURSOR_BIN=agent \ + -e CLI_CLINE_BIN=cline \ + -e CLI_CONTINUE_BIN=cn \ + -e CLI_CONFIG_HOME=/host-home \ + -e CLI_ALLOW_CONFIG_WRITES=true \ + -v ~/.local/bin:/host-local/bin:ro \ + -v ~/.nvm/versions/node/v22.16.0:/host-node:ro \ + -v /path/to/host/codex/bin:/host-codex:ro \ + -v ~/.codex:/host-home/.codex:rw \ + -v ~/.claude:/host-home/.claude:rw \ + -v ~/.factory:/host-home/.factory:rw \ + -v ~/.openclaw:/host-home/.openclaw:rw \ + -v ~/.cursor:/host-home/.cursor:rw \ + -v ~/.config/cursor:/host-home/.config/cursor:rw \ + -v omniroute-data:/app/data \ + omniroute:base +``` + +Notes: + +- `runner-cli` currently bundles `@openai/codex`, `@anthropic-ai/claude-code`, `droid`, and `openclaw@latest`. +- `runner-cli` uses Node 22 Debian slim to satisfy OpenClaw runtime requirements. +- Host CLI mount mode is Linux-first. On Docker Desktop (Mac/Windows), prefer `runner-cli`. +- `Continue CLI` executable is usually `cn`, so set `CLI_CONTINUE_BIN=cn` in host mode. +- `Cursor Agent` executable is commonly `agent` in `~/.local/bin`, so set `CLI_CURSOR_BIN=agent` and include that directory in `CLI_EXTRA_PATHS`. +- Cursor auth/config typically live in `~/.config/cursor` and `~/.cursor`; mount both for host-integrated mode. + +Quick runtime validation: + +```bash +curl -s http://localhost:20128/api/cli-tools/codex-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/claude-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/openclaw-settings | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/runtime/cursor | jq '{installed,runnable,command,commandPath,configPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/runtime/cline | jq '{installed,runnable,commandPath,runtimeMode,reason}' +curl -s http://localhost:20128/api/cli-tools/runtime/continue | jq '{installed,runnable,commandPath,runtimeMode,reason}' +``` + +Container defaults: + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +Useful commands: + +```bash +docker logs -f omniroute +docker restart omniroute +docker stop omniroute && docker rm omniroute +``` + +### Environment Variables + +| Variable | Default | Description | +| ---------------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | +| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret for dashboard auth cookie (**change in production**) | +| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | +| `DATA_DIR` | `~/.omniroute` | Primary data directory (`db.json`, `usage.json`, `log.txt`, `call_logs/`) | +| `XDG_CONFIG_HOME` | unset | Optional base dir on Linux/macOS when `DATA_DIR` is unset (`$XDG_CONFIG_HOME/omniroute`) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://omniroute.dev` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://omniroute.dev` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | +| `ENABLE_SOCKS5_PROXY` | `false` | Enables backend/runtime SOCKS5 support for outbound proxy | +| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `false` | Shows SOCKS5 option in dashboard proxy UI | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | Allow multiple connections for each OpenAI/Anthropic-compatible custom node | +| `CLI_MODE` | `auto` | CLI runtime profile: `auto`, `host`, or `container` | +| `CLI_EXTRA_PATHS` | empty | Extra `PATH` entries used for CLI detection/healthcheck (split by `:` on Linux) | +| `CLI_CONFIG_HOME` | runtime home (`os.homedir`) | Base directory used to read/write CLI config files | +| `CLI_ALLOW_CONFIG_WRITES` | `true` | If `false`, `/api/cli-tools/*` `POST`/`DELETE` return `403` | +| `CLI_CLAUDE_BIN` | `claude` | Override command/path used for Claude CLI detection | +| `CLI_CODEX_BIN` | `codex` | Override command/path used for Codex CLI detection | +| `CLI_DROID_BIN` | `droid` | Override command/path used for Droid CLI detection | +| `CLI_OPENCLAW_BIN` | `openclaw` | Override command/path used for OpenClaw CLI detection | +| `CLI_CURSOR_BIN` | `agent` | Override command/path used for Cursor Agent detection (`agent`, fallback `cursor`) | +| `CLI_CLINE_BIN` | empty | Optional override for Cline runtime detection (set `cline` if you have local CLI) | +| `CLI_ROO_BIN` | empty | Optional override for Roo runtime detection | +| `CLI_CONTINUE_BIN` | empty | Optional override for Continue runtime detection (commonly `cn`) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | + +Notes: + +- Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`. +- On Windows, `APPDATA` can be used for local storage path resolution. +- `CLI_EXTRA_PATHS` uses the platform delimiter (`:` on Linux/macOS, `;` on Windows). +- `INSTANCE_NAME` appears in older docs/env templates, but is currently not used at runtime. + +### Runtime Files and Storage + +- Main app state: `${DATA_DIR}/db.json` (providers, combos, aliases, keys, settings), managed by `src/lib/localDb.js`. +- Usage history and logs: `${DATA_DIR}/usage.json`, `${DATA_DIR}/log.txt`, `${DATA_DIR}/call_logs/`, managed by `src/lib/usageDb.js`. +- Optional request/translator logs: `/logs/...` when `ENABLE_REQUEST_LOGS=true`. +- Legacy files under `~/.omniroute` are migrated automatically when `DATA_DIR` (or `XDG_CONFIG_HOME`) points to a different location. + +
+ +--- + +## 📊 Available Models + +
+View all available models + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**Gemini CLI (`gc/`)** - FREE: + +- `gc/gemini-3-flash-preview` +- `gc/gemini-2.5-pro` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5` +- `gh/claude-4.5-sonnet` + +**GLM (`glm/`)** - $0.6/1M: + +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +- `minimax/MiniMax-M2.1` + +**iFlow (`if/`)** - FREE: + +- `if/kimi-k2-thinking` +- `if/qwen3-coder-plus` +- `if/deepseek-r1` + +**Qwen (`qw/`)** - FREE: + +- `qw/qwen3-coder-plus` +- `qw/qwen3-coder-flash` + +**Kiro (`kr/`)** - FREE: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` + +**DeepSeek (`ds/`)** - API Key: + +- `ds/deepseek-chat` +- `ds/deepseek-reasoner` + +**Groq (`groq/`)** - API Key: + +- `groq/llama-3.3-70b-versatile` +- `groq/llama-4-maverick-17b-128e-instruct` +- `groq/qwen-qwq-32b` +- `groq/gpt-oss-120b` + +**xAI (`xai/`)** - API Key: + +- `xai/grok-4` +- `xai/grok-4-0709-fast-reasoning` +- `xai/grok-code-mini` +- `xai/grok-3-beta` + +**Mistral (`mistral/`)** - API Key: + +- `mistral/mistral-large-2501` +- `mistral/codestral-2501` +- `mistral/mistral-medium-2505` + +**Perplexity (`pplx/`)** - API Key: + +- `pplx/sonar-pro` +- `pplx/sonar` + +**Together AI (`together/`)** - API Key: + +- `together/meta-llama/Llama-3.3-70B-Instruct-Turbo` +- `together/deepseek-ai/DeepSeek-R1` +- `together/Qwen/Qwen3-235B-A22B` + +**Fireworks AI (`fireworks/`)** - API Key: + +- `fireworks/accounts/fireworks/models/deepseek-v3p1` +- `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct` + +**Cerebras (`cerebras/`)** - API Key: + +- `cerebras/llama-3.3-70b` +- `cerebras/llama-4-scout-17b-16e-instruct` +- `cerebras/qwen-3-32b` + +**Cohere (`cohere/`)** - API Key: + +- `cohere/command-r-plus-08-2024` +- `cohere/command-a-03-2025` + +**NVIDIA NIM (`nvidia/`)** - API Key: + +- `nvidia/nvidia/llama-3.3-70b-instruct` +- `nvidia/deepseek/deepseek-r1` + +
+ +--- + +## 🧩 Advanced Features + +
+Custom Models + +Add any model ID to any provider without waiting for an app update: + +```bash +# Via API +curl -X POST http://localhost:20128/api/provider-models \ + -H "Content-Type: application/json" \ + -d '{"provider": "openai", "modelId": "gpt-4.5-preview", "modelName": "GPT-4.5 Preview"}' + +# List custom models +curl http://localhost:20128/api/provider-models?provider=openai + +# Remove +curl -X DELETE "http://localhost:20128/api/provider-models?provider=openai&model=gpt-4.5-preview" +``` + +Or use the Dashboard: **Providers → [Provider] → Custom Models** section. + +Custom models appear in `/v1/models` with `custom: true` flag. + +
+ +
+Dedicated Provider Routes + +Route requests directly to a specific provider with model validation: + +```bash +# Chat completions for a specific provider +curl -X POST http://localhost:20128/v1/providers/openai/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}' + +# Embeddings for a specific provider +curl -X POST http://localhost:20128/v1/providers/openai/embeddings \ + -H "Content-Type: application/json" \ + -d '{"model": "text-embedding-3-small", "input": "Hello world"}' + +# Image generation for a specific provider +curl -X POST http://localhost:20128/v1/providers/fireworks/images/generations \ + -H "Content-Type: application/json" \ + -d '{"model": "flux-1-schnell", "prompt": "A sunset over the ocean"}' +``` + +The provider prefix is auto-added if missing. Mismatched models return `400`. + +
+ +
+Network Proxy Configuration + +Configure outbound proxies globally, or per-provider via structured proxy objects: + +```bash +# Set global proxy +curl -X PUT http://localhost:20128/api/settings/proxy \ + -H "Content-Type: application/json" \ + -d '{"global": {"type":"http","host":"proxy.example.com","port":"8080"}}' + +# Set per-provider proxy +curl -X PUT http://localhost:20128/api/settings/proxy \ + -H "Content-Type: application/json" \ + -d '{"providers": {"openai": {"type":"socks5","host":"proxy.example.com","port":"1080"}}}' + +# Test a proxy before saving +curl -X POST http://localhost:20128/api/settings/proxy/test \ + -H "Content-Type: application/json" \ + -d '{"proxy":{"type":"socks5","host":"proxy.example.com","port":"1080"}}' + +# Get current config +curl http://localhost:20128/api/settings/proxy +``` + +Notes: + +- SOCKS5 is feature-flagged. Enable backend/runtime with `ENABLE_SOCKS5_PROXY=true`. +- To display SOCKS5 in the dashboard UI, also set `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true`. +- Requests are fail-closed when a configured proxy fails (no direct-connection fallback). + +**Precedence:** Key-specific → Combo-specific → Provider-specific → Global → Environment (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`). + +
+ +
+Model Catalog API + +Get all available models grouped by provider with type metadata: + +```bash +curl http://localhost:20128/api/models/catalog +``` + +Returns: + +```json +{ + "catalog": { + "openai": { + "provider": "OpenAI", + "active": true, + "models": [ + { + "id": "openai/gpt-4o", + "name": "GPT-4o", + "type": "chat", + "custom": false + }, + { + "id": "openai/text-embedding-3-small", + "type": "embedding", + "custom": false + } + ] + } + } +} +``` + +Types: `chat`, `embedding`, `image`. Custom models are flagged with `custom: true`. + +
+ +--- + +## 🐛 Troubleshooting + +**"Language model did not provide messages"** + +- Provider quota exhausted → Check dashboard quota tracker +- Solution: Use combo fallback or switch to cheaper tier + +**Rate limiting** + +- Subscription quota out → Fallback to GLM/MiniMax +- Add combo: `cc/claude-opus-4-6 → glm/glm-4.7 → if/kimi-k2-thinking` + +**OAuth token expired** + +- Auto-refreshed by OmniRoute +- If issues persist: Dashboard → Provider → Reconnect + +**High costs** + +- Check usage stats in Dashboard +- Switch primary model to GLM/MiniMax +- Use free tier (Gemini CLI, iFlow) for non-critical tasks + +**Dashboard opens on wrong port** + +- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**Cloud sync errors** + +- Verify `BASE_URL` points to your running instance (example: `http://localhost:20128`) +- Verify `CLOUD_URL` points to your expected cloud endpoint (example: `https://omniroute.dev`) +- Keep `NEXT_PUBLIC_*` values aligned with server-side values when possible. + +**Cloud endpoint `stream=false` returns 500 (`Unexpected token 'd'...`)** + +- Symptom usually appears on public cloud endpoint (`https://omniroute.dev/v1`) for non-streaming calls. +- Root cause: upstream returns SSE payload (`data: ...`) while client expects JSON. +- Workaround: use `stream=true` for cloud direct calls. +- Local OmniRoute runtime includes SSE→JSON fallback for non-streaming calls when upstream returns `text/event-stream`. + +**Cloud says connected, but request still fails with `Invalid API key`** + +- Create a fresh key from local dashboard (`/api/keys`) and run cloud sync (`Enable Cloud` then `Sync Now`). +- Old/non-synced keys can still return `401` on cloud even if local endpoint works. + +**CLI tool shows not installed inside Docker** + +- Check runtime fields from `/api/cli-tools/*` (`installed`, `runnable`, `reason`, `commandPath`). +- For portable mode, use image target `runner-cli` (bundled `codex`/`claude`/`droid`). +- For host mount mode, set `CLI_EXTRA_PATHS` and mount host bin directory as read-only. +- If `installed=true` and `runnable=false`, binary was found but failed healthcheck. + +**First login not working** + +- Check `INITIAL_PASSWORD` in `.env` +- If unset, fallback password is `123456` + +**No request logs under `logs/`** + +- Set `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Charts**: Recharts (SVG, accessible) +- **Database**: LowDB (JSON file-based) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys +- **Testing**: Playwright (E2E) + Node.js test runner (unit) +- **Monorepo**: npm workspaces (`@omniroute/open-sse`) +- **CI/CD**: Dependabot (weekly npm + GH Actions) +- **Compliance**: `/terms` and `/privacy` pages + +--- + +## 📝 API Reference + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### Embeddings + +```bash +POST http://localhost:20128/v1/embeddings +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "nebius/Qwen/Qwen3-Embedding-8B", + "input": "The food was delicious" +} +``` + +Available embedding providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA. + +```bash +# List all embedding models +GET http://localhost:20128/v1/embeddings +``` + +### Image Generation + +```bash +POST http://localhost:20128/v1/images/generations +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "openai/dall-e-3", + "prompt": "A beautiful sunset over mountains", + "size": "1024x1024" +} +``` + +Available image providers: OpenAI (DALL-E), xAI (Grok Image), Together AI (FLUX), Fireworks AI. + +```bash +# List all image models +GET http://localhost:20128/v1/images/generations +``` + +### List Models + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Returns all chat, embedding, and image models + combos in OpenAI format +``` + +### Compatibility Endpoints + +- `POST /v1/chat/completions` +- `POST /v1/messages` +- `POST /v1/responses` +- `POST /v1/embeddings` (**NEW**) +- `POST /v1/images/generations` (**NEW**) +- `GET /v1/models` (chat + embedding + image) +- `POST /v1/messages/count_tokens` +- `GET /v1beta/models` +- `POST /v1beta/models/{...path}` (Gemini-style `generateContent`) +- `POST /v1/api/chat` (Ollama-style transform path) + +### Cloud Validation Scripts + +Added test scripts under `tester/security/`: + +- `tester/security/test-docker-hardening.sh` + - Builds Docker image and validates hardening checks (`/api/cloud/auth` auth guard, `REQUIRE_API_KEY`, secure auth cookie behavior). +- `tester/security/test-cloud-openai-compatible.sh` + - Sends a direct OpenAI-compatible request to cloud endpoint (`https://omniroute.dev/v1/chat/completions`) with provided model/key. +- `tester/security/test-cloud-sync-and-call.sh` + - End-to-end flow: create local key -> enable/sync cloud -> call cloud endpoint with retry. + - Includes fallback check with `stream=true` to distinguish auth errors from non-streaming parse issues. +- `tester/security/test-cli-runtime.sh` + - Validates Docker CLI runtime profiles (`runner-base`, `runner-cli`, host mount mode, write policy, non-executable regression). + +Security note for cloud test scripts: + +- Never hardcode real API keys in scripts/commits. +- Provide keys only via environment variables: + - `API_KEY`, `CLOUD_API_KEY`, or `OPENAI_API_KEY` (supported by `test-cloud-openai-compatible.sh`) +- Example: + +```bash +OPENAI_API_KEY="your-cloud-key" bash tester/security/test-cloud-openai-compatible.sh +``` + +Expected behavior from recent validation: + +- Local runtime (`http://127.0.0.1:20128/v1/chat/completions`): works with `stream=false` and `stream=true`. +- Docker runtime (same API path exposed by container): hardening checks pass, cloud auth guard works, strict API key mode works when enabled. +- Public cloud endpoint (`https://omniroute.dev/v1/chat/completions`): + - `stream=true`: expected to succeed (SSE chunks returned). + - `stream=false`: may fail with `500` + parse error (`Unexpected token 'd'`) when upstream returns SSE content to a non-streaming client path. + +### Dashboard and Management API + +- Auth/settings: `/api/auth/login`, `/api/auth/logout`, `/api/settings`, `/api/settings/require-login` +- Provider management: `/api/providers`, `/api/providers/[id]`, `/api/providers/[id]/test`, `/api/providers/[id]/models`, `/api/providers/validate`, `/api/provider-nodes*` +- OAuth flows: `/api/oauth/[provider]/[action]` (+ provider-specific imports like Cursor/Kiro) +- Routing config: `/api/models/alias`, `/api/combos*`, `/api/keys*`, `/api/pricing` +- Usage/logs: `/api/usage/history`, `/api/usage/logs`, `/api/usage/request-logs`, `/api/usage/[connectionId]` +- Cloud sync: `/api/sync/cloud`, `/api/sync/initialize`, `/api/cloud/*` +- CLI helpers: `/api/cli-tools/claude-settings`, `/api/cli-tools/codex-settings`, `/api/cli-tools/droid-settings`, `/api/cli-tools/openclaw-settings` + - Generic runtime status: `/api/cli-tools/runtime/[toolId]` (covers `claude`, `codex`, `droid`, `openclaw`, `cursor`, `cline`, `roo`, `continue`) + - CLI `GET` responses expose runtime fields: `installed`, `runnable`, `command`, `commandPath`, `runtimeMode`, `reason` + +### Authentication Behavior + +- Dashboard routes (`/dashboard/*`) use `auth_token` cookie protection. +- Login uses saved password hash when present; otherwise it falls back to `INITIAL_PASSWORD`. +- `requireLogin` can be toggled via `/api/settings/require-login`. + +### Request Processing (High Level) + +1. Client sends request to `/v1/*`. +2. Route handler calls `handleChat` (`src/sse/handlers/chat.js`), `handleEmbedding`, or `handleImageGeneration`. +3. Model is resolved (direct provider/model or alias/combo resolution). +4. Credentials are selected from local DB with account availability filtering. +5. For chat: `handleChatCore` detects format and translates request. For embeddings/images: handler proxies directly to upstream. +6. Provider executor sends upstream request. +7. Stream is translated back to client format when needed (chat). Embeddings and images return JSON. +8. Usage/logging is recorded (`src/lib/usageDb.js`). +9. Fallback applies on provider/account/model errors according to combo rules. + +Full architecture reference: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) + +--- + +## 📧 Support + +- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) +- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) +- **Original Project**: [9router by decolua](https://github.com/decolua/9router) + +--- + +## 👥 Contributors + +Thanks to all contributors who helped make OmniRoute better! + +[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=100&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors) + +--- + +## 📊 Star Chart + +[![Star Chart](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute) + +### How to Contribute + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +--- + +## 🙏 Acknowledgments + +Special thanks to **CLIProxyAPI** - the original Go implementation that inspired this JavaScript port. + +--- + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +
+ Built with ❤️ for developers who code 24/7 +
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000000..486afaafeb --- /dev/null +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..ea3c72f679 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..8f13ab5ed0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 (`...`) 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: `/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 `/logs//` 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://:20128/v1` when `PORT=20128` diff --git a/docs/CODEBASE_DOCUMENTATION.md b/docs/CODEBASE_DOCUMENTATION.md new file mode 100644 index 0000000000..d05dbaee28 --- /dev/null +++ b/docs/CODEBASE_DOCUMENTATION.md @@ -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
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"] +``` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..f443835250 --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/images/omniroute.png b/images/omniroute.png new file mode 100644 index 0000000000..861a863cca Binary files /dev/null and b/images/omniroute.png differ diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000000..28a7ea5788 --- /dev/null +++ b/next.config.mjs @@ -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; diff --git a/open-sse/.npmignore b/open-sse/.npmignore new file mode 100644 index 0000000000..0b7b5690d9 --- /dev/null +++ b/open-sse/.npmignore @@ -0,0 +1,8 @@ +node_modules/ +*.log +.DS_Store +test/ +*.test.js +.env +.env.* + diff --git a/open-sse/config/codexInstructions.js b/open-sse/config/codexInstructions.js new file mode 100644 index 0000000000..b903ac068d --- /dev/null +++ b/open-sse/config/codexInstructions.js @@ -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`; diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js new file mode 100644 index 0000000000..004858f74a --- /dev/null +++ b/open-sse/config/constants.js @@ -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:"]; diff --git a/open-sse/config/credentialLoader.js b/open-sse/config/credentialLoader.js new file mode 100644 index 0000000000..0f6cbd8eeb --- /dev/null +++ b/open-sse/config/credentialLoader.js @@ -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; +} diff --git a/open-sse/config/defaultThinkingSignature.js b/open-sse/config/defaultThinkingSignature.js new file mode 100644 index 0000000000..15fd797e9e --- /dev/null +++ b/open-sse/config/defaultThinkingSignature.js @@ -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 = "..."; diff --git a/open-sse/config/embeddingRegistry.js b/open-sse/config/embeddingRegistry.js new file mode 100644 index 0000000000..9ed26527fe --- /dev/null +++ b/open-sse/config/embeddingRegistry.js @@ -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; +} diff --git a/open-sse/config/imageRegistry.js b/open-sse/config/imageRegistry.js new file mode 100644 index 0000000000..acb8bf2972 --- /dev/null +++ b/open-sse/config/imageRegistry.js @@ -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; +} diff --git a/open-sse/config/ollamaModels.js b/open-sse/config/ollamaModels.js new file mode 100644 index 0000000000..ccc0b85905 --- /dev/null +++ b/open-sse/config/ollamaModels.js @@ -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", + }, + }, + ], +}; diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js new file mode 100644 index 0000000000..0cde3a5c4d --- /dev/null +++ b/open-sse/config/providerModels.js @@ -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] || []; +} diff --git a/open-sse/config/providerRegistry.js b/open-sse/config/providerRegistry.js new file mode 100644 index 0000000000..b27fef8c63 --- /dev/null +++ b/open-sse/config/providerRegistry.js @@ -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); +} diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js new file mode 100644 index 0000000000..a88eff3ee6 --- /dev/null +++ b/open-sse/executors/antigravity.js @@ -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; diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js new file mode 100644 index 0000000000..9c0791e4b9 --- /dev/null +++ b/open-sse/executors/base.js @@ -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; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js new file mode 100644 index 0000000000..0c1343b642 --- /dev/null +++ b/open-sse/executors/codex.js @@ -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; + } +} diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js new file mode 100644 index 0000000000..7f9a018dc1 --- /dev/null +++ b/open-sse/executors/cursor.js @@ -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; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js new file mode 100644 index 0000000000..05269a5c59 --- /dev/null +++ b/open-sse/executors/default.js @@ -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; diff --git a/open-sse/executors/gemini-cli.js b/open-sse/executors/gemini-cli.js new file mode 100644 index 0000000000..24e4c3afd4 --- /dev/null +++ b/open-sse/executors/gemini-cli.js @@ -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; diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js new file mode 100644 index 0000000000..7f8f21cc71 --- /dev/null +++ b/open-sse/executors/github.js @@ -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; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js new file mode 100644 index 0000000000..a262a492a4 --- /dev/null +++ b/open-sse/executors/index.js @@ -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"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js new file mode 100644 index 0000000000..a9fe3a670e --- /dev/null +++ b/open-sse/executors/kiro.js @@ -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; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js new file mode 100644 index 0000000000..60fa60df01 --- /dev/null +++ b/open-sse/handlers/chatCore.js @@ -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; +} diff --git a/open-sse/handlers/embeddings.js b/open-sse/handlers/embeddings.js new file mode 100644 index 0000000000..7a74fc66d9 --- /dev/null +++ b/open-sse/handlers/embeddings.js @@ -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}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration.js b/open-sse/handlers/imageGeneration.js new file mode 100644 index 0000000000..1174bea951 --- /dev/null +++ b/open-sse/handlers/imageGeneration.js @@ -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}`, + }; + } +} diff --git a/open-sse/handlers/responseTranslator.js b/open-sse/handlers/responseTranslator.js new file mode 100644 index 0000000000..79dc42b388 --- /dev/null +++ b/open-sse/handlers/responseTranslator.js @@ -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; +} diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.js new file mode 100644 index 0000000000..1066726ecd --- /dev/null +++ b/open-sse/handlers/responsesHandler.js @@ -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": "*", + }, + }), + }; +} diff --git a/open-sse/handlers/sseParser.js b/open-sse/handlers/sseParser.js new file mode 100644 index 0000000000..608f5c349e --- /dev/null +++ b/open-sse/handlers/sseParser.js @@ -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 || {}, + }; +} diff --git a/open-sse/handlers/usageExtractor.js b/open-sse/handlers/usageExtractor.js new file mode 100644 index 0000000000..f11148bdc6 --- /dev/null +++ b/open-sse/handlers/usageExtractor.js @@ -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; +} diff --git a/open-sse/index.js b/open-sse/index.js new file mode 100644 index 0000000000..6a73bef841 --- /dev/null +++ b/open-sse/index.js @@ -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"; diff --git a/open-sse/package.json b/open-sse/package.json new file mode 100644 index 0000000000..0e212295db --- /dev/null +++ b/open-sse/package.json @@ -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", + "./*": "./*" + } +} diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js new file mode 100644 index 0000000000..ae5b64a687 --- /dev/null +++ b/open-sse/services/accountFallback.js @@ -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", + }; +} diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js new file mode 100644 index 0000000000..21569c99c7 --- /dev/null +++ b/open-sse/services/combo.js @@ -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 + * @param {Function} [options.isModelAvailable] - Optional pre-check: (modelStr) => Promise + * @param {Object} options.log - Logger object + * @returns {Promise} + */ +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" }, + }); +} diff --git a/open-sse/services/comboConfig.js b/open-sse/services/comboConfig.js new file mode 100644 index 0000000000..2e2ad77c7e --- /dev/null +++ b/open-sse/services/comboConfig.js @@ -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 }; +} diff --git a/open-sse/services/comboMetrics.js b/open-sse/services/comboMetrics.js new file mode 100644 index 0000000000..021e913e74 --- /dev/null +++ b/open-sse/services/comboMetrics.js @@ -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(); +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js new file mode 100644 index 0000000000..605759c397 --- /dev/null +++ b/open-sse/services/model.js @@ -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, + }; +} diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js new file mode 100644 index 0000000000..5d640ba208 --- /dev/null +++ b/open-sse/services/provider.js @@ -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; +} diff --git a/open-sse/services/rateLimitManager.js b/open-sse/services/rateLimitManager.js new file mode 100644 index 0000000000..16aac27ab6 --- /dev/null +++ b/open-sse/services/rateLimitManager.js @@ -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} 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; +} diff --git a/open-sse/services/rateLimitSemaphore.js b/open-sse/services/rateLimitSemaphore.js new file mode 100644 index 0000000000..b6c149321f --- /dev/null +++ b/open-sse/services/rateLimitSemaphore.js @@ -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} */ +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} 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(); +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js new file mode 100644 index 0000000000..831e79e6cc --- /dev/null +++ b/open-sse/services/tokenRefresh.js @@ -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 +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} 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; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js new file mode 100644 index 0000000000..185dd9918d --- /dev/null +++ b/open-sse/services/usage.js @@ -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." }; + } +} diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js new file mode 100644 index 0000000000..e1c4318c20 --- /dev/null +++ b/open-sse/transformer/responsesTransformer.js @@ -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 tags) + if (delta.content) { + let content = delta.content; + + if (content.includes("")) { + state.inThinking = true; + content = content.replace("", ""); + startReasoning(controller, idx); + } + + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + + 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(); + }, + }); +} diff --git a/open-sse/translator/formats.js b/open-sse/translator/formats.js new file mode 100644 index 0000000000..5ff2098b9d --- /dev/null +++ b/open-sse/translator/formats.js @@ -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", +}; diff --git a/open-sse/translator/helpers/claudeHelper.js b/open-sse/translator/helpers/claudeHelper.js new file mode 100644 index 0000000000..8eabbc2ee1 --- /dev/null +++ b/open-sse/translator/helpers/claudeHelper.js @@ -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; +} diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.js new file mode 100644 index 0000000000..0b9241152f --- /dev/null +++ b/open-sse/translator/helpers/geminiHelper.js @@ -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; +} diff --git a/open-sse/translator/helpers/maxTokensHelper.js b/open-sse/translator/helpers/maxTokensHelper.js new file mode 100644 index 0000000000..6755e32c4d --- /dev/null +++ b/open-sse/translator/helpers/maxTokensHelper.js @@ -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; +} diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.js new file mode 100644 index 0000000000..a435fd3dcb --- /dev/null +++ b/open-sse/translator/helpers/openaiHelper.js @@ -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; +} diff --git a/open-sse/translator/helpers/responsesApiHelper.js b/open-sse/translator/helpers/responsesApiHelper.js new file mode 100644 index 0000000000..012b140be9 --- /dev/null +++ b/open-sse/translator/helpers/responsesApiHelper.js @@ -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; +} diff --git a/open-sse/translator/helpers/toolCallHelper.js b/open-sse/translator/helpers/toolCallHelper.js new file mode 100644 index 0000000000..5b0a0c8ced --- /dev/null +++ b/open-sse/translator/helpers/toolCallHelper.js @@ -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; +} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js new file mode 100644 index 0000000000..6aa4bcd56a --- /dev/null +++ b/open-sse/translator/index.js @@ -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() {} diff --git a/open-sse/translator/request/antigravity-to-openai.js b/open-sse/translator/request/antigravity-to-openai.js new file mode 100644 index 0000000000..4a6402d17e --- /dev/null +++ b/open-sse/translator/request/antigravity-to-openai.js @@ -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); diff --git a/open-sse/translator/request/claude-to-gemini.js b/open-sse/translator/request/claude-to-gemini.js new file mode 100644 index 0000000000..3b5c7f21ba --- /dev/null +++ b/open-sse/translator/request/claude-to-gemini.js @@ -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); diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js new file mode 100644 index 0000000000..040b54d2ae --- /dev/null +++ b/open-sse/translator/request/claude-to-openai.js @@ -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); diff --git a/open-sse/translator/request/gemini-to-openai.js b/open-sse/translator/request/gemini-to-openai.js new file mode 100644 index 0000000000..ad067e070e --- /dev/null +++ b/open-sse/translator/request/gemini-to-openai.js @@ -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); diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js new file mode 100644 index 0000000000..c3176ee2d4 --- /dev/null +++ b/open-sse/translator/request/openai-responses.js @@ -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); diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js new file mode 100644 index 0000000000..5a2ccb3b0a --- /dev/null +++ b/open-sse/translator/request/openai-to-claude.js @@ -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); diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js new file mode 100644 index 0000000000..e8e7485dfc --- /dev/null +++ b/open-sse/translator/request/openai-to-cursor.js @@ -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); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js new file mode 100644 index 0000000000..3945852499 --- /dev/null +++ b/open-sse/translator/request/openai-to-gemini.js @@ -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); diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js new file mode 100644 index 0000000000..8529b2cf3f --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro.js @@ -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); diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js new file mode 100644 index 0000000000..57e5f9f9b9 --- /dev/null +++ b/open-sse/translator/response/claude-to-openai.js @@ -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: "" })); + } 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: "" })); + 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); diff --git a/open-sse/translator/response/cursor-to-openai.js b/open-sse/translator/response/cursor-to-openai.js new file mode 100644 index 0000000000..b254691844 --- /dev/null +++ b/open-sse/translator/response/cursor-to-openai.js @@ -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); diff --git a/open-sse/translator/response/gemini-to-claude.js b/open-sse/translator/response/gemini-to-claude.js new file mode 100644 index 0000000000..353ced803b --- /dev/null +++ b/open-sse/translator/response/gemini-to-claude.js @@ -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); diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js new file mode 100644 index 0000000000..a678618b8d --- /dev/null +++ b/open-sse/translator/response/gemini-to-openai.js @@ -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); diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.js new file mode 100644 index 0000000000..5e9e8a840d --- /dev/null +++ b/open-sse/translator/response/kiro-to-openai.js @@ -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: `${content}`, + }, + 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); diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js new file mode 100644 index 0000000000..bd4efa4a0e --- /dev/null +++ b/open-sse/translator/response/openai-responses.js @@ -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("")) { + state.inThinking = true; + content = content.replace("", ""); + startReasoning(state, emit, idx); + } + + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join("
"); + 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); diff --git a/open-sse/translator/response/openai-to-antigravity.js b/open-sse/translator/response/openai-to-antigravity.js new file mode 100644 index 0000000000..30bee0ac54 --- /dev/null +++ b/open-sse/translator/response/openai-to-antigravity.js @@ -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); diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js new file mode 100644 index 0000000000..ddc97e8dc2 --- /dev/null +++ b/open-sse/translator/response/openai-to-claude.js @@ -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); diff --git a/open-sse/types.d.ts b/open-sse/types.d.ts new file mode 100644 index 0000000000..5b180e1a70 --- /dev/null +++ b/open-sse/types.d.ts @@ -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; +} + +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; + /** Resolved model info */ + modelInfo: ModelInfo; + /** Provider credentials to use */ + credentials: ProviderCredentials; + /** Request-scoped logger */ + log: Logger; + /** Raw client request body (before translation) */ + clientRawRequest?: Record; + /** 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; + /** Callback on successful upstream response */ + onRequestSuccess?: () => Promise; +} + +// ============ Logger ============ + +export interface Logger { + debug(tag: string, message: string, data?: Record): void; + info(tag: string, message: string, data?: Record): void; + warn(tag: string, message: string, data?: Record): void; + error(tag: string, message: string, data?: Record): void; +} + +export interface TaggedLogger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): 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; + /** 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 | 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; +} diff --git a/open-sse/utils/bypassHandler.js b/open-sse/utils/bypassHandler.js new file mode 100644 index 0000000000..c0f266ed66 --- /dev/null +++ b/open-sse/utils/bypassHandler.js @@ -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, + }, + ]; +} diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js new file mode 100644 index 0000000000..8b6bbe5c8c --- /dev/null +++ b/open-sse/utils/cursorChecksum.js @@ -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; diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js new file mode 100644 index 0000000000..39b78e2bf9 --- /dev/null +++ b/open-sse/utils/cursorProtobuf.js @@ -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; diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js new file mode 100644 index 0000000000..12888e057d --- /dev/null +++ b/open-sse/utils/error.js @@ -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}`; +} diff --git a/open-sse/utils/logger.js b/open-sse/utils/logger.js new file mode 100644 index 0000000000..eca067b9f8 --- /dev/null +++ b/open-sse/utils/logger.js @@ -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__ + * @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; diff --git a/open-sse/utils/networkProxy.js b/open-sse/utils/networkProxy.js new file mode 100644 index 0000000000..89a9d03529 --- /dev/null +++ b/open-sse/utils/networkProxy.js @@ -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; +} diff --git a/open-sse/utils/ollamaTransform.js b/open-sse/utils/ollamaTransform.js new file mode 100644 index 0000000000..6f1c788ec9 --- /dev/null +++ b/open-sse/utils/ollamaTransform.js @@ -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": "*" }, + }); +} diff --git a/open-sse/utils/proxyDispatcher.js b/open-sse/utils/proxyDispatcher.js new file mode 100644 index 0000000000..0a4f235e1c --- /dev/null +++ b/open-sse/utils/proxyDispatcher.js @@ -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; +} diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js new file mode 100644 index 0000000000..5c45b4f715 --- /dev/null +++ b/open-sse/utils/proxyFetch.js @@ -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; diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js new file mode 100644 index 0000000000..714312ceca --- /dev/null +++ b/open-sse/utils/requestLogger.js @@ -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} 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(); +} diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js new file mode 100644 index 0000000000..08d86b3e4f --- /dev/null +++ b/open-sse/utils/stream.js @@ -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, + }); +} diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js new file mode 100644 index 0000000000..2f6bffe144 --- /dev/null +++ b/open-sse/utils/streamHandler.js @@ -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 + ); +} diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js new file mode 100644 index 0000000000..bcffe219e8 --- /dev/null +++ b/open-sse/utils/streamHelpers.js @@ -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`; +} diff --git a/open-sse/utils/thinkTagParser.js b/open-sse/utils/thinkTagParser.js new file mode 100644 index 0000000000..46b3c58a14 --- /dev/null +++ b/open-sse/utils/thinkTagParser.js @@ -0,0 +1,146 @@ +/** + * Think Tag Parser + * + * Parses ... 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 tags. + * + * Usage: + * import { extractThinkTags, hasThinkTags } from "./thinkTagParser.js"; + * + * const { reasoning, content } = extractThinkTags(rawOutput); + * // reasoning = "step-by-step thinking..." + * // content = "final answer..." + */ + +const THINK_OPEN = ""; +const THINK_CLOSE = ""; + +/** + * 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 ) 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 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 }; +} diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js new file mode 100644 index 0000000000..4401f74a6a --- /dev/null +++ b/open-sse/utils/usageTracking.js @@ -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(() => {}); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..5b06c10f27 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "omniroute-app", + "version": "0.2.73", + "description": "OmniRoute web dashboard", + "private": true, + "workspaces": [ + "open-sse" + ], + "scripts": { + "dev": "next dev --webpack --port 20128", + "build": "next build --webpack", + "start": "next start --port 20128", + "lint": "eslint .", + "test:plan3": "node --test tests/unit/plan3-p0.test.mjs", + "test:fixes": "node --test tests/unit/fixes-p1.test.mjs", + "test": "npm run build", + "test:e2e": "npx playwright test", + "check": "npm run lint && npm run test", + "prepare": "husky" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", + "bottleneck": "^2.19.5", + "express": "^5.2.1", + "fetch-socks": "^1.3.2", + "fs": "^0.0.1-security", + "http-proxy-middleware": "^3.0.5", + "https-proxy-agent": "^7.0.6", + "jose": "^6.1.3", + "lowdb": "^7.0.1", + "monaco-editor": "^0.55.1", + "next": "^16.1.6", + "node-machine-id": "^1.1.12", + "open": "^11.0.0", + "ora": "^9.1.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "recharts": "^3.7.0", + "selfsigned": "^5.5.0", + "undici": "^7.19.2", + "uuid": "^13.0.0", + "zod": "^4.3.6", + "zustand": "^5.0.10" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@tailwindcss/postcss": "^4.1.18", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.2.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.8.1", + "tailwindcss": "^4", + "typescript": "^5.9.3" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,mjs}": [ + "prettier --write", + "eslint --fix --no-error-on-unmatched-pattern" + ], + "*.{json,md,yml,yaml,css}": [ + "prettier --write" + ] + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..89ac6d2fe6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "html", + use: { + baseURL: "http://localhost:20128", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:20128", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000000..999cd7b9d2 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const postcssConfig = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default postcssConfig; diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000000..850db17bcd --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,7 @@ +export default { + semi: true, + singleQuote: false, + tabWidth: 2, + trailingComma: "es5", + printWidth: 100, +}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000000..a72e45bef3 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + 9 + + + + + + + + diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000000..004145cddf --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000000..567f17b0d7 --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000000..5174b28c56 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/anthropic-m.png b/public/providers/anthropic-m.png new file mode 100644 index 0000000000..feea1edd1a Binary files /dev/null and b/public/providers/anthropic-m.png differ diff --git a/public/providers/anthropic.png b/public/providers/anthropic.png new file mode 100644 index 0000000000..dc58142bd8 Binary files /dev/null and b/public/providers/anthropic.png differ diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png new file mode 100644 index 0000000000..6fe0feaee7 Binary files /dev/null and b/public/providers/antigravity.png differ diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png new file mode 100644 index 0000000000..a4ace23a6c Binary files /dev/null and b/public/providers/cerebras.png differ diff --git a/public/providers/claude.png b/public/providers/claude.png new file mode 100644 index 0000000000..c223ad464b Binary files /dev/null and b/public/providers/claude.png differ diff --git a/public/providers/cline.png b/public/providers/cline.png new file mode 100644 index 0000000000..be2418999f Binary files /dev/null and b/public/providers/cline.png differ diff --git a/public/providers/codex.png b/public/providers/codex.png new file mode 100644 index 0000000000..41a9dd775e Binary files /dev/null and b/public/providers/codex.png differ diff --git a/public/providers/cohere.png b/public/providers/cohere.png new file mode 100644 index 0000000000..60a0fafe80 Binary files /dev/null and b/public/providers/cohere.png differ diff --git a/public/providers/continue.png b/public/providers/continue.png new file mode 100644 index 0000000000..f54685be8d Binary files /dev/null and b/public/providers/continue.png differ diff --git a/public/providers/copilot.png b/public/providers/copilot.png new file mode 100644 index 0000000000..9907963e40 Binary files /dev/null and b/public/providers/copilot.png differ diff --git a/public/providers/cursor.png b/public/providers/cursor.png new file mode 100644 index 0000000000..ec02b070ba Binary files /dev/null and b/public/providers/cursor.png differ diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png new file mode 100644 index 0000000000..5df2f5040f Binary files /dev/null and b/public/providers/deepseek.png differ diff --git a/public/providers/droid.png b/public/providers/droid.png new file mode 100644 index 0000000000..28b8350a78 Binary files /dev/null and b/public/providers/droid.png differ diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png new file mode 100644 index 0000000000..f7233caac7 Binary files /dev/null and b/public/providers/fireworks.png differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png new file mode 100644 index 0000000000..5b72946d3a Binary files /dev/null and b/public/providers/gemini-cli.png differ diff --git a/public/providers/gemini.png b/public/providers/gemini.png new file mode 100644 index 0000000000..9df2d30179 Binary files /dev/null and b/public/providers/gemini.png differ diff --git a/public/providers/github.png b/public/providers/github.png new file mode 100644 index 0000000000..9907963e40 Binary files /dev/null and b/public/providers/github.png differ diff --git a/public/providers/glm.png b/public/providers/glm.png new file mode 100644 index 0000000000..cee2b24be2 Binary files /dev/null and b/public/providers/glm.png differ diff --git a/public/providers/groq.png b/public/providers/groq.png new file mode 100644 index 0000000000..b6bb68a130 Binary files /dev/null and b/public/providers/groq.png differ diff --git a/public/providers/iflow.png b/public/providers/iflow.png new file mode 100644 index 0000000000..1bddeae625 Binary files /dev/null and b/public/providers/iflow.png differ diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png new file mode 100644 index 0000000000..147dac0c5b Binary files /dev/null and b/public/providers/kilocode.png differ diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png new file mode 100644 index 0000000000..422b7f9628 Binary files /dev/null and b/public/providers/kimi-coding.png differ diff --git a/public/providers/kimi.png b/public/providers/kimi.png new file mode 100644 index 0000000000..422b7f9628 Binary files /dev/null and b/public/providers/kimi.png differ diff --git a/public/providers/kiro.png b/public/providers/kiro.png new file mode 100644 index 0000000000..166c7c72c9 Binary files /dev/null and b/public/providers/kiro.png differ diff --git a/public/providers/minimax-cn.png b/public/providers/minimax-cn.png new file mode 100644 index 0000000000..a8b9bf7ea6 Binary files /dev/null and b/public/providers/minimax-cn.png differ diff --git a/public/providers/minimax.png b/public/providers/minimax.png new file mode 100644 index 0000000000..a8b9bf7ea6 Binary files /dev/null and b/public/providers/minimax.png differ diff --git a/public/providers/mistral.png b/public/providers/mistral.png new file mode 100644 index 0000000000..68001b9782 Binary files /dev/null and b/public/providers/mistral.png differ diff --git a/public/providers/nebius.png b/public/providers/nebius.png new file mode 100644 index 0000000000..14c878ece7 Binary files /dev/null and b/public/providers/nebius.png differ diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png new file mode 100644 index 0000000000..9215a386ff Binary files /dev/null and b/public/providers/nvidia.png differ diff --git a/public/providers/oai-cc.png b/public/providers/oai-cc.png new file mode 100644 index 0000000000..56a7a3e618 Binary files /dev/null and b/public/providers/oai-cc.png differ diff --git a/public/providers/oai-r.png b/public/providers/oai-r.png new file mode 100644 index 0000000000..0dbd61c734 Binary files /dev/null and b/public/providers/oai-r.png differ diff --git a/public/providers/openai.png b/public/providers/openai.png new file mode 100644 index 0000000000..d4367af077 Binary files /dev/null and b/public/providers/openai.png differ diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png new file mode 100644 index 0000000000..7ef77ac754 Binary files /dev/null and b/public/providers/openclaw.png differ diff --git a/public/providers/openrouter.png b/public/providers/openrouter.png new file mode 100644 index 0000000000..0b4802d1d4 Binary files /dev/null and b/public/providers/openrouter.png differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png new file mode 100644 index 0000000000..4e9d56fab2 Binary files /dev/null and b/public/providers/perplexity.png differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png new file mode 100644 index 0000000000..0fc2d1b308 Binary files /dev/null and b/public/providers/qwen.png differ diff --git a/public/providers/roo.png b/public/providers/roo.png new file mode 100644 index 0000000000..0503577502 Binary files /dev/null and b/public/providers/roo.png differ diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png new file mode 100644 index 0000000000..a73581450d Binary files /dev/null and b/public/providers/siliconflow.png differ diff --git a/public/providers/together.png b/public/providers/together.png new file mode 100644 index 0000000000..7a53d4c435 Binary files /dev/null and b/public/providers/together.png differ diff --git a/public/providers/xai.png b/public/providers/xai.png new file mode 100644 index 0000000000..e75ae2505f Binary files /dev/null and b/public/providers/xai.png differ diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000000..7705396033 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000000..b2b2a44f6e --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000000..0a0c5b54dc --- /dev/null +++ b/restart.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +PORT=20128 +MAX_ATTEMPTS=3 +echo "🔄 Reiniciando aplicação na porta $PORT..." + +# Função para matar processos pela porta +kill_by_port() { + local attempt=1 + + while [ $attempt -le $MAX_ATTEMPTS ]; do + echo "Tentativa $attempt de $MAX_ATTEMPTS..." + + # Tenta encontrar processos usando lsof + PIDS=$(lsof -ti:$PORT 2>/dev/null) + + if [ -z "$PIDS" ]; then + echo "✓ Porta $PORT está livre" + return 0 + fi + + echo "🔴 Matando processos na porta $PORT: $PIDS" + + # Tenta SIGTERM primeiro (mais gentil) + if [ $attempt -eq 1 ]; then + for PID in $PIDS; do + kill $PID 2>/dev/null && echo " - SIGTERM enviado para PID $PID" + done + sleep 2 + else + # Se não funcionou, usa SIGKILL (força) + for PID in $PIDS; do + kill -9 $PID 2>/dev/null && echo " - SIGKILL enviado para PID $PID" + done + sleep 1 + fi + + # Fallback: tenta fuser se lsof não funcionou + if command -v fuser >/dev/null 2>&1; then + fuser -k -9 $PORT/tcp 2>/dev/null && echo " - fuser utilizado como fallback" + sleep 1 + fi + + attempt=$((attempt + 1)) + done + + # Última verificação + if lsof -ti:$PORT >/dev/null 2>&1; then + echo "❌ Erro: Não foi possível liberar a porta $PORT após $MAX_ATTEMPTS tentativas" + echo "Processos ainda ativos:" + lsof -i:$PORT 2>/dev/null + return 1 + fi + + return 0 +} + +# Executa a função de kill +if ! kill_by_port; then + echo "" + echo "💡 Sugestão: Execute manualmente:" + echo " sudo lsof -ti:$PORT | xargs kill -9" + exit 1 +fi + +echo "" +echo "🚀 Iniciando npm run dev..." +npm run build && npm run start diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js new file mode 100644 index 0000000000..b8c5c9e398 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -0,0 +1,267 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardSkeleton } from "@/shared/components"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { + PROVIDER_MODELS, + getModelsByProviderId, + PROVIDER_ID_TO_ALIAS, +} from "@/shared/constants/models"; +import { + ClaudeToolCard, + CodexToolCard, + DroidToolCard, + OpenClawToolCard, + ClineToolCard, + KiloToolCard, + DefaultToolCard, + AntigravityToolCard, +} from "./components"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function CLIToolsPageClient({ machineId }) { + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedTool, setExpandedTool] = useState(null); + const [modelMappings, setModelMappings] = useState({}); + const [cloudEnabled, setCloudEnabled] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + + useEffect(() => { + fetchConnections(); + loadCloudSettings(); + fetchApiKeys(); + }, []); + + const loadCloudSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setCloudEnabled(data.cloudEnabled || false); + } + } catch (error) { + console.log("Error loading cloud settings:", error); + } + }; + + const fetchApiKeys = async () => { + try { + const res = await fetch("/api/keys"); + if (res.ok) { + const data = await res.json(); + setApiKeys(data.keys || []); + } + } catch (error) { + console.log("Error fetching API keys:", error); + } + }; + + const fetchConnections = async () => { + try { + const res = await fetch("/api/providers"); + const data = await res.json(); + if (res.ok) { + setConnections(data.connections || []); + } + } catch (error) { + console.log("Error fetching connections:", error); + } finally { + setLoading(false); + } + }; + + const getActiveProviders = () => { + return connections.filter((c) => c.isActive !== false); + }; + + const getAllAvailableModels = () => { + const activeProviders = getActiveProviders(); + const models = []; + const seenModels = new Set(); + + activeProviders.forEach((conn) => { + const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; + const providerModels = getModelsByProviderId(conn.provider); + providerModels.forEach((m) => { + const modelValue = `${alias}/${m.id}`; + if (!seenModels.has(modelValue)) { + seenModels.add(modelValue); + models.push({ + value: modelValue, + label: `${alias}/${m.id}`, + provider: conn.provider, + alias: alias, + connectionName: conn.name, + modelId: m.id, + }); + } + }); + }); + + return models; + }; + + const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => { + setModelMappings((prev) => { + // Prevent unnecessary updates if value hasn't changed + if (prev[toolId]?.[modelAlias] === targetModel) { + return prev; + } + return { + ...prev, + [toolId]: { + ...prev[toolId], + [modelAlias]: targetModel, + }, + }; + }); + }, []); + + const getBaseUrl = () => { + if (cloudEnabled && CLOUD_URL) { + return CLOUD_URL; + } + if (typeof window !== "undefined") { + return window.location.origin; + } + return "http://localhost:20128"; + }; + + if (loading) { + return ( +
+
+ + + +
+
+ ); + } + + const availableModels = getAllAvailableModels(); + const hasActiveProviders = availableModels.length > 0; + + const renderToolCard = (toolId, tool) => { + const commonProps = { + tool, + isExpanded: expandedTool === toolId, + onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId), + baseUrl: getBaseUrl(), + apiKeys, + }; + + switch (toolId) { + case "claude": + return ( + + handleModelMappingChange(toolId, alias, target) + } + hasActiveProviders={hasActiveProviders} + cloudEnabled={cloudEnabled} + /> + ); + case "codex": + return ( + + ); + case "droid": + return ( + + ); + case "openclaw": + return ( + + ); + case "antigravity": + return ( + + ); + case "cline": + return ( + + ); + case "kilo": + return ( + + ); + default: + return ( + + ); + } + }; + + return ( +
+ {!hasActiveProviders && ( + +
+ warning +
+

+ No active providers +

+

+ Please add and connect providers first to configure CLI tools. +

+
+
+
+ )} + +
+ {Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js new file mode 100644 index 0000000000..282bd2e7b2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -0,0 +1,451 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components"; +import Image from "next/image"; + +export default function AntigravityToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + hasActiveProviders, + cloudEnabled, +}) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [sudoPassword, setSudoPassword] = useState(""); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [message, setMessage] = useState(null); + const [modelMappings, setModelMappings] = useState({}); + const [modalOpen, setModalOpen] = useState(false); + const [currentEditingAlias, setCurrentEditingAlias] = useState(null); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !status) { + fetchStatus(); + loadSavedMappings(); + } + }, [isExpanded, status]); + + const loadSavedMappings = async () => { + try { + const res = await fetch("/api/cli-tools/antigravity-mitm/alias?tool=antigravity"); + if (res.ok) { + const data = await res.json(); + const aliases = data.aliases || {}; + + if (Object.keys(aliases).length > 0) { + setModelMappings(aliases); + } + } + } catch (error) { + console.log("Error loading saved mappings:", error); + } + }; + + const fetchStatus = async () => { + try { + const res = await fetch("/api/cli-tools/antigravity-mitm"); + if (res.ok) { + const data = await res.json(); + setStatus(data); + } + } catch (error) { + console.log("Error fetching status:", error); + setStatus({ running: false }); + } + }; + + // Windows uses UAC dialog, no sudo needed + const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); + + const handleStart = () => { + if (isWindows || status?.hasCachedPassword) { + doStart(""); + } else { + setShowPasswordModal(true); + setMessage(null); + } + }; + + const handleStop = () => { + if (isWindows || status?.hasCachedPassword) { + doStop(""); + } else { + setShowPasswordModal(true); + setMessage(null); + } + }; + + const doStart = async (password) => { + setLoading(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_omniroute" : null); + + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }), + }); + + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "MITM started" }); + setShowPasswordModal(false); + setSudoPassword(""); + fetchStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to start" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const doStop = async (password) => { + setLoading(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sudoPassword: password }), + }); + + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "MITM stopped" }); + setShowPasswordModal(false); + setSudoPassword(""); + fetchStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to stop" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const handleConfirmPassword = () => { + if (!sudoPassword.trim()) { + setMessage({ type: "error", text: "Sudo password is required" }); + return; + } + if (status?.running) { + doStop(sudoPassword); + } else { + doStart(sudoPassword); + } + }; + + const openModelSelector = (alias) => { + setCurrentEditingAlias(alias); + setModalOpen(true); + }; + + const handleModelSelect = (model) => { + if (currentEditingAlias) { + setModelMappings((prev) => ({ + ...prev, + [currentEditingAlias]: model.value, + })); + } + }; + + const handleModelMappingChange = (alias, value) => { + setModelMappings((prev) => ({ + ...prev, + [alias]: value, + })); + }; + + const handleSaveMappings = async () => { + setLoading(true); + setMessage(null); + + try { + const res = await fetch("/api/cli-tools/antigravity-mitm/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "antigravity", mappings: modelMappings }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to save mappings"); + } + + setMessage({ type: "success", text: "Mappings saved!" }); + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + } + }; + + const isRunning = status?.running; + + return ( + +
+
+
+ {tool.name} { + e.target.style.display = "none"; + }} + /> +
+
+
+

{tool.name}

+ {isRunning ? ( + + Active + + ) : ( + + Inactive + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {/* Start/Stop Button - always on top */} +
+ {isRunning ? ( + + ) : ( + + )} +
+ + {message?.type === "error" && ( +
+ error + {message.text} +
+ )} + + {/* When running: API Key + Model Mappings */} + {isRunning && ( + <> +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled + ? "No API keys - Create one in Keys page" + : "sk_omniroute (default)"} + + )} +
+ + {tool.defaultModels.map((model) => ( +
+ + {model.name} + + + arrow_forward + + handleModelMappingChange(model.alias, e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelMappings[model.alias] && ( + + )} +
+ ))} + +
+ +
+ + )} + + {/* When stopped: how it works */} + {!isRunning && ( +
+

+ How it works: Intercepts + Antigravity traffic via DNS redirect, letting you reroute models through OmniRoute. +

+
+ 1. Generates SSL cert & adds to system keychain + + 2. Redirects{" "} + + daily-cloudcode-pa.googleapis.com + {" "} + → localhost + + 3. Maps Antigravity models to any provider via OmniRoute +
+
+ )} +
+ )} + + {/* Password Modal */} + { + setShowPasswordModal(false); + setSudoPassword(""); + setMessage(null); + }} + title="Sudo Password Required" + size="sm" + > +
+
+ warning +

+ Required for SSL certificate and DNS configuration +

+
+ + setSudoPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !loading) handleConfirmPassword(); + }} + /> + + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + +
+
+
+ + {/* Model Select Modal */} + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + title={`Select model for ${currentEditingAlias}`} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js new file mode 100644 index 0000000000..d795c07d6c --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -0,0 +1,575 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function ClaudeToolCard({ + tool, + isExpanded, + onToggle, + activeProviders, + modelMappings, + onModelMappingChange, + baseUrl, + hasActiveProviders, + apiKeys, + cloudEnabled, +}) { + const [claudeStatus, setClaudeStatus] = useState(null); + const [checkingClaude, setCheckingClaude] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [showInstallGuide, setShowInstallGuide] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [currentEditingAlias, setCurrentEditingAlias] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModels = useRef(false); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(claudeStatus?.installed && claudeStatus?.runnable); + + const getConfigStatus = () => { + if (!cliReady) return null; + const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; + if (!currentUrl) return "not_configured"; + const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !claudeStatus) { + checkClaudeStatus(); + fetchModelAliases(); + fetchBackups(); + } + }, [isExpanded, claudeStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (claudeStatus?.installed && !hasInitializedModels.current) { + hasInitializedModels.current = true; + const env = claudeStatus.settings?.env || {}; + + tool.defaultModels.forEach((model) => { + if (model.envKey) { + const value = env[model.envKey] || model.defaultValue || ""; + // Only sync initial values from file once + if (value) { + onModelMappingChange(model.alias, value); + } + } + }); + // Only set selectedApiKey if it exists in apiKeys list + const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN; + if (tokenFromFile && apiKeys?.some((k) => k.key === tokenFromFile)) { + setSelectedApiKey(tokenFromFile); + } + } + }, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]); + + const checkClaudeStatus = async () => { + setCheckingClaude(true); + try { + const res = await fetch("/api/cli-tools/claude-settings"); + const data = await res.json(); + setClaudeStatus(data); + } catch (error) { + setClaudeStatus({ installed: false, error: error.message }); + } finally { + setCheckingClaude(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() }; + + // Get key from dropdown, fallback to first key or sk_omniroute for localhost + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_omniroute" : null); + + if (keyToUse) { + env.ANTHROPIC_AUTH_TOKEN = keyToUse; + } + + tool.defaultModels.forEach((model) => { + const targetModel = modelMappings[model.alias]; + if (targetModel && model.envKey) env[model.envKey] = targetModel; + }); + const res = await fetch("/api/cli-tools/claude-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ env }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + setClaudeStatus((prev) => ({ + ...prev, + hasBackup: true, + settings: { ...prev?.settings, env }, + })); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + tool.defaultModels.forEach((model) => + onModelMappingChange(model.alias, model.defaultValue || "") + ); + setSelectedApiKey(""); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const openModelSelector = (alias) => { + setCurrentEditingAlias(alias); + setModalOpen(true); + }; + + const handleModelSelect = (model) => { + if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value); + }; + + // Generate settings.json content for manual copy + const getManualConfigs = () => { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : ""; + const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse }; + tool.defaultModels.forEach((model) => { + const targetModel = modelMappings[model.alias]; + if (targetModel && model.envKey) env[model.envKey] = targetModel; + }); + + return [ + { + filename: "~/.claude/settings.json", + content: JSON.stringify({ env }, null, 2), + }, + ]; + }; + + // ── Backups ── + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?tool=claude"); + const data = await res.json(); + if (res.ok) setBackups(data.backups || []); + } catch (error) { + console.log("Error fetching backups:", error); + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "claude", backupId }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored!" }); + checkClaudeStatus(); + fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed to restore" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoringBackup(null); + } + }; + + return ( + +
+
+
+ {tool.name} { + e.target.style.display = "none"; + }} + /> +
+
+
+

{tool.name}

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

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingClaude && ( +
+ progress_activity + Checking Claude CLI... +
+ )} + + {!checkingClaude && claudeStatus && !cliReady && ( +
+
+ warning +
+

+ {claudeStatus.installed + ? "Claude CLI not runnable" + : "Claude CLI not installed"} +

+

+ {claudeStatus.installed + ? `Claude CLI was found but failed runtime healthcheck${claudeStatus.reason ? ` (${claudeStatus.reason})` : ""}.` + : "Please install Claude CLI to use this feature."} +

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux / Windows:

+ + npm install -g @anthropic-ai/claude-code + +
+

+ After installation, run{" "} + claude to + verify. +

+
+
+ )} +
+ )} + + {!checkingClaude && cliReady && ( + <> +
+ {/* Current Base URL */} + {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && ( +
+ + Current + + + arrow_forward + + + {claudeStatus.settings.env.ANTHROPIC_BASE_URL} + +
+ )} + + {/* Base URL */} +
+ + Base URL + + + arrow_forward + + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled + ? "No API keys - Create one in Keys page" + : "sk_omniroute (default)"} + + )} +
+ + {/* Model Mappings */} + {tool.defaultModels.map((model) => ( +
+ + {model.name} + + + arrow_forward + + onModelMappingChange(model.alias, e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelMappings[model.alias] && ( + + )} +
+ ))} +
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + + +
+ +
+ + {/* Backups Section */} + {showBackups && ( +
+

+ history + Config Backups +

+ {backups.length === 0 ? ( +

+ No backups yet. Backups are created automatically before each Apply or Reset. +

+ ) : ( +
+ {backups.map((b) => ( +
+ + description + + + {b.id} + + + {new Date(b.createdAt).toLocaleString()} + + +
+ ))} +
+ )} +
+ )} + + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + + setShowManualConfigModal(false)} + title="Claude CLI - Manual Configuration" + configs={getManualConfigs()} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js new file mode 100644 index 0000000000..aea31f67cd --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js @@ -0,0 +1,488 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function ClineToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [clineStatus, setClineStatus] = useState(null); + const [checkingCline, setCheckingCline] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(clineStatus?.installed && clineStatus?.runnable); + + const getConfigStatus = () => { + if (!cliReady) return null; + if (!clineStatus.hasOmniRoute) return "not_configured"; + const baseUrlVal = clineStatus.settings?.openAiBaseUrl || ""; + const localMatch = baseUrlVal.includes("localhost") || baseUrlVal.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && baseUrlVal.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !clineStatus) { + checkClineStatus(); + fetchModelAliases(); + fetchBackups(); + } + }, [isExpanded, clineStatus]); + + useEffect(() => { + if (clineStatus?.settings && !hasInitializedModel.current) { + const currentModel = clineStatus.settings.openAiModelId; + if (currentModel) { + setSelectedModel(currentModel); + hasInitializedModel.current = true; + } + } + }, [clineStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + if (res.ok) { + const data = await res.json(); + setModelAliases(data.aliases || {}); + } + } catch { + /* ignore */ + } + }; + + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?toolId=cline"); + if (res.ok) { + const data = await res.json(); + setBackups(data.backups || []); + } + } catch { + /* ignore */ + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ toolId: "cline", backupId }), + }); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored! Reloading status..." }); + await checkClineStatus(); + await fetchBackups(); + } else { + const data = await res.json(); + setMessage({ type: "error", text: data.error || "Failed to restore backup" }); + } + } catch (e) { + setMessage({ type: "error", text: e.message }); + } finally { + setRestoringBackup(null); + } + }; + + const checkClineStatus = async () => { + setCheckingCline(true); + try { + const res = await fetch("/api/cli-tools/cline-settings"); + const data = await res.json(); + setClineStatus(data); + } catch (error) { + setClineStatus({ error: error.message }); + } finally { + setCheckingCline(false); + } + }; + + const getEffectiveBaseUrl = () => { + if (customBaseUrl) return customBaseUrl; + return baseUrl || "http://localhost:20128"; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const effectiveBaseUrl = getEffectiveBaseUrl(); + const normalizedBaseUrl = effectiveBaseUrl.endsWith("/v1") + ? effectiveBaseUrl + : `${effectiveBaseUrl}/v1`; + + const res = await fetch("/api/cli-tools/cline-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: normalizedBaseUrl, + apiKey: selectedApiKey || "sk_omniroute", + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Applied!" }); + await checkClineStatus(); + await fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Reset!" }); + setSelectedModel(""); + hasInitializedModel.current = false; + await checkClineStatus(); + await fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleSelectModel = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const handleManualConfig = (config) => { + if (config.model) setSelectedModel(config.model); + if (config.apiKey) setSelectedApiKey(config.apiKey); + if (config.baseUrl) setCustomBaseUrl(config.baseUrl); + setShowManualConfigModal(false); + }; + + const renderStatusBadge = () => { + if (!cliReady) return null; + const badges = { + configured: { + class: "bg-green-500/10 text-green-600 dark:text-green-400", + text: "Connected", + }, + not_configured: { + class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + text: "Not configured", + }, + other: { class: "bg-blue-500/10 text-blue-600 dark:text-blue-400", text: "Custom config" }, + }; + const badge = badges[configStatus]; + if (!badge) return null; + return ( + + {badge.text} + + ); + }; + + return ( + +
+
+
+ {tool.image ? ( + {tool.name} { + e.target.style.display = "none"; + }} + /> + ) : ( + + terminal + + )} +
+
+
+

{tool.name}

+ {renderStatusBadge()} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingCline && ( +
+ + progress_activity + + Checking Cline CLI... +
+ )} + + {clineStatus && !checkingCline && ( +
+ {/* Runtime status */} +
+ + {cliReady ? "check_circle" : "warning"} + +
+

+ {cliReady + ? "Cline CLI detected and ready" + : clineStatus.installed + ? "Cline CLI installed but not runnable" + : "Cline CLI not detected"} +

+ {clineStatus.commandPath && ( +

+ Binary:{" "} + + {clineStatus.commandPath} + +

+ )} + {clineStatus.globalStatePath && ( +

+ Config:{" "} + + {clineStatus.globalStatePath} + +

+ )} +
+
+ + {cliReady && ( + <> + {/* Current config info */} + {configStatus === "configured" && ( +
+ + check_circle + +
+

+ OmniRoute is configured as OpenAI-compatible provider +

+

+ Provider: openai • Model:{" "} + {clineStatus.settings?.openAiModelId || "—"} +

+
+
+ )} + + {/* Model selection */} +
+ +
+ setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + +
+
+ + {/* API Key selection */} +
+ + {apiKeys && apiKeys.length > 0 ? ( + + ) : ( +

+ {cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"} +

+ )} +
+ + {/* Action buttons */} +
+ + {configStatus === "configured" && ( + + )} +
+ + {/* Message */} + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + {/* Backups section */} +
+ + {showBackups && backups.length > 0 && ( +
+ {backups.map((b) => ( +
+
+ {b.originalFile} + + {new Date(b.createdAt).toLocaleString()} + +
+ +
+ ))} +
+ )} + {showBackups && backups.length === 0 && ( +

No backups available.

+ )} +
+ + )} +
+ )} +
+ )} + + setModalOpen(false)} + onSelect={handleSelectModel} + selectedModel={selectedModel} + activeProviders={activeProviders} + title="Select Model for Cline" + /> + setShowManualConfigModal(false)} + onApply={handleManualConfig} + currentConfig={{ + model: selectedModel, + apiKey: selectedApiKey, + baseUrl: customBaseUrl || baseUrl, + }} + title="Cline Manual Configuration" + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js new file mode 100644 index 0000000000..45adcd60e7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -0,0 +1,731 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +export default function CodexToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [codexStatus, setCodexStatus] = useState(null); + const [checkingCodex, setCheckingCodex] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [showInstallGuide, setShowInstallGuide] = useState(false); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + // Profiles state + const [profiles, setProfiles] = useState([]); + const [showProfiles, setShowProfiles] = useState(false); + const [newProfileName, setNewProfileName] = useState(""); + const [savingProfile, setSavingProfile] = useState(false); + const [activatingProfile, setActivatingProfile] = useState(null); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(codexStatus?.installed && codexStatus?.runnable); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !codexStatus) { + checkCodexStatus(); + fetchModelAliases(); + fetchProfiles(); + fetchBackups(); + } + }, [isExpanded, codexStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + // Parse model from config content (don't sync URL - always use baseUrl from props) + useEffect(() => { + if (codexStatus?.config) { + const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); + if (modelMatch) setSelectedModel(modelMatch[1]); + } + }, [codexStatus]); + + const getConfigStatus = () => { + if (!cliReady) return null; + if (!codexStatus.config) return "not_configured"; + const hasBaseUrl = + codexStatus.config.includes(baseUrl) || + codexStatus.config.includes("localhost") || + codexStatus.config.includes("127.0.0.1"); + return hasBaseUrl ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || `${baseUrl}/v1`; + // Ensure URL ends with /v1 + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; + + const checkCodexStatus = async () => { + setCheckingCodex(true); + try { + const res = await fetch("/api/cli-tools/codex-settings"); + const data = await res.json(); + setCodexStatus(data); + } catch (error) { + setCodexStatus({ installed: false, error: error.message }); + } finally { + setCheckingCodex(false); + } + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + // Use sk_omniroute for localhost if no key, otherwise use selected key + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : selectedApiKey; + + const res = await fetch("/api/cli-tools/codex-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkCodexStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkCodexStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + // ── Profiles ── + const fetchProfiles = async () => { + try { + const res = await fetch("/api/cli-tools/codex-profiles"); + const data = await res.json(); + if (res.ok) setProfiles(data.profiles || []); + } catch (error) { + console.log("Error fetching profiles:", error); + } + }; + + const handleSaveProfile = async () => { + if (!newProfileName.trim()) return; + setSavingProfile(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/codex-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newProfileName.trim() }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: `Profile "${newProfileName}" saved!` }); + setNewProfileName(""); + fetchProfiles(); + } else { + setMessage({ type: "error", text: data.error || "Failed to save profile" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setSavingProfile(false); + } + }; + + const handleActivateProfile = async (profileId) => { + setActivatingProfile(profileId); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/codex-profiles", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profileId }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Profile activated!" }); + checkCodexStatus(); + fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed to activate profile" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setActivatingProfile(null); + } + }; + + const handleDeleteProfile = async (profileId) => { + try { + const res = await fetch("/api/cli-tools/codex-profiles", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profileId }), + }); + if (res.ok) fetchProfiles(); + } catch (error) { + console.log("Error deleting profile:", error); + } + }; + + // ── Backups ── + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?tool=codex"); + const data = await res.json(); + if (res.ok) setBackups(data.backups || []); + } catch (error) { + console.log("Error fetching backups:", error); + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "codex", backupId }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored!" }); + checkCodexStatus(); + fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed to restore" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoringBackup(null); + } + }; + + const getManualConfigs = () => { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : ""; + + const configContent = `# OmniRoute Configuration for Codex CLI +model = "${selectedModel}" +model_provider = "omniroute" + +[model_providers.omniroute] +name = "OmniRoute" +base_url = "${getEffectiveBaseUrl()}" +wire_api = "responses" +`; + + const authContent = JSON.stringify( + { + OPENAI_API_KEY: keyToUse, + }, + null, + 2 + ); + + return [ + { + filename: "~/.codex/config.toml", + content: configContent, + }, + { + filename: "~/.codex/auth.json", + content: authContent, + }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { + e.target.style.display = "none"; + }} + /> +
+
+
+

{tool.name}

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

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingCodex && ( +
+ progress_activity + Checking Codex CLI... +
+ )} + + {!checkingCodex && codexStatus && !cliReady && ( +
+
+ warning +
+

+ {codexStatus.installed ? "Codex CLI not runnable" : "Codex CLI not installed"} +

+

+ {codexStatus.installed + ? `Codex CLI was found but failed runtime healthcheck${codexStatus.reason ? ` (${codexStatus.reason})` : ""}.` + : "Please install Codex CLI to use auto-apply feature."} +

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux / Windows:

+ + npm install -g @openai/codex + +
+

+ After installation, run{" "} + codex to + verify. +

+
+

+ Codex uses{" "} + + ~/.codex/auth.json + {" "} + with{" "} + + OPENAI_API_KEY + + . Click "Apply" to auto-configure. +

+
+
+
+ )} +
+ )} + + {!checkingCodex && cliReady && ( + <> +
+ {/* Current Base URL */} + {codexStatus?.config && + (() => { + const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); + const currentBaseUrl = parsed ? parsed[1] : null; + return currentBaseUrl ? ( +
+ + Current + + + arrow_forward + + + {currentBaseUrl} + +
+ ) : null; + })()} + + {/* Base URL */} +
+ + Base URL + + + arrow_forward + + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && ( + + )} +
+ + {/* API Key */} +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled + ? "No API keys - Create one in Keys page" + : "sk_omniroute (default)"} + + )} +
+ + {/* Model */} +
+ + Model + + + arrow_forward + + setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {selectedModel && ( + + )} +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + + +
+ + +
+ + {/* Profiles Section */} + {showProfiles && ( +
+

+ manage_accounts + Saved Profiles +

+ {profiles.length === 0 ? ( +

+ No profiles saved yet. Save current config as a profile below. +

+ ) : ( +
+ {profiles.map((p) => ( +
+ + person + + {p.name} + + {p.authLabel} + + + +
+ ))} +
+ )} +
+ setNewProfileName(e.target.value)} + placeholder="Profile name (e.g. Personal Account)" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + onKeyDown={(e) => e.key === "Enter" && handleSaveProfile()} + /> + +
+
+ )} + + {/* Backups Section */} + {showBackups && ( +
+

+ history + Config Backups +

+ {backups.length === 0 ? ( +

+ No backups yet. Backups are created automatically before each Apply or Reset. +

+ ) : ( +
+ {backups.map((b) => ( +
+ + description + + + {b.id} + + + {new Date(b.createdAt).toLocaleString()} + + +
+ ))} +
+ )} +
+ )} + + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex" + /> + + setShowManualConfigModal(false)} + title="Codex CLI - Manual Configuration" + configs={getManualConfigs()} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js new file mode 100644 index 0000000000..e4cb49fb9e --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -0,0 +1,526 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { Card, Button, ModelSelectModal } from "@/shared/components"; +import Image from "next/image"; + +export default function DefaultToolCard({ + toolId, + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders = [], + cloudEnabled = false, +}) { + const [copiedField, setCopiedField] = useState(null); + const [showModelModal, setShowModelModal] = useState(false); + const [modelValue, setModelValue] = useState(""); + const [runtimeStatus, setRuntimeStatus] = useState(null); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + const runtimeFetchStartedRef = useRef(false); + + // Initialize state directly with computed value + const [selectedApiKey, setSelectedApiKey] = useState(() => + apiKeys?.length > 0 ? apiKeys[0].key : "" + ); + + // Persist and restore model selection per tool via localStorage + useEffect(() => { + const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`); + if (savedModel) setModelValue(savedModel); + const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`); + if (savedKey && apiKeys?.some((k) => k.key === savedKey)) setSelectedApiKey(savedKey); + }, [toolId, apiKeys]); + + const handleModelChange = useCallback( + (value) => { + setModelValue(value); + if (value) { + localStorage.setItem(`omniroute-cli-model-${toolId}`, value); + } else { + localStorage.removeItem(`omniroute-cli-model-${toolId}`); + } + }, + [toolId] + ); + + const handleApiKeyChange = useCallback( + (value) => { + setSelectedApiKey(value); + if (value) { + localStorage.setItem(`omniroute-cli-key-${toolId}`, value); + } + }, + [toolId] + ); + + useEffect(() => { + if (!isExpanded || runtimeStatus || runtimeFetchStartedRef.current) return; + + runtimeFetchStartedRef.current = true; + fetch(`/api/cli-tools/runtime/${toolId}`) + .then((res) => res.json()) + .then((data) => setRuntimeStatus(data)) + .catch((error) => setRuntimeStatus({ error: error?.message || "runtime_check_failed" })); + }, [isExpanded, runtimeStatus, toolId]); + + const replaceVars = (text) => { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : "your-api-key"; + + const normalizedBaseUrl = baseUrl || "http://localhost:20128"; + const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") + ? normalizedBaseUrl + : `${normalizedBaseUrl}/v1`; + + return text + .replace(/\{\{baseUrl\}\}/g, baseUrlWithV1) + .replace(/\{\{apiKey\}\}/g, keyToUse) + .replace(/\{\{model\}\}/g, modelValue || "provider/model-id"); + }; + + const handleCopy = async (text, field) => { + await navigator.clipboard.writeText(replaceVars(text)); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }; + + const handleSelectModel = (model) => { + handleModelChange(model.value); + }; + + const hasActiveProviders = activeProviders.length > 0; + const checkingRuntime = isExpanded && runtimeStatus === null; + + // Save config to file (for tools that support it, like Continue) + const handleSaveConfig = async () => { + setSaving(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : ""; + + const normalizedBaseUrl = baseUrl || "http://localhost:20128"; + const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") + ? normalizedBaseUrl + : `${normalizedBaseUrl}/v1`; + + const res = await fetch(`/api/cli-tools/guide-settings/${toolId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: baseUrlWithV1, + apiKey: keyToUse, + model: modelValue, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Configuration saved!" }); + } else { + setMessage({ type: "error", text: data.error || "Failed to save" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setSaving(false); + } + }; + + // Check if this tool supports direct config file write + const supportsDirectSave = ["continue"].includes(toolId); + + const renderApiKeySelector = () => { + return ( +
+ {apiKeys && apiKeys.length > 0 ? ( + <> + + + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_omniroute"} + + )} +
+ ); + }; + + const renderModelSelector = () => { + return ( +
+ handleModelChange(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelValue && ( + <> + + + + )} +
+ ); + }; + + const renderNotes = () => { + if (!tool.notes || tool.notes.length === 0) return null; + + return ( +
+ {tool.notes.map((note, index) => { + if (note.type === "cloudCheck" && cloudEnabled) return null; + + const isWarning = note.type === "warning"; + const isError = note.type === "cloudCheck" && !cloudEnabled; + + let bgClass = "bg-blue-500/10 border-blue-500/30"; + let textClass = "text-blue-600 dark:text-blue-400"; + let iconClass = "text-blue-500"; + let icon = "info"; + + if (isWarning) { + bgClass = "bg-yellow-500/10 border-yellow-500/30"; + textClass = "text-yellow-600 dark:text-yellow-400"; + iconClass = "text-yellow-500"; + icon = "warning"; + } else if (isError) { + bgClass = "bg-red-500/10 border-red-500/30"; + textClass = "text-red-600 dark:text-red-400"; + iconClass = "text-red-500"; + icon = "error"; + } + + return ( +
+ {icon} +

{note.text}

+
+ ); + })} +
+ ); + }; + + const canShowGuide = () => { + if (tool.requiresCloud && !cloudEnabled) return false; + return true; + }; + + const renderGuideSteps = () => { + if (!tool.guideSteps) return

Coming soon...

; + + return ( +
+ {checkingRuntime && ( +
+ + progress_activity + + Checking runtime... +
+ )} + {!checkingRuntime && runtimeStatus && !runtimeStatus.error && ( +
+ + {runtimeStatus.reason === "not_required" + ? "info" + : runtimeStatus.installed && runtimeStatus.runnable + ? "check_circle" + : "warning"} + +
+

+ {runtimeStatus.reason === "not_required" + ? "Guide-only integration: no local binary required" + : runtimeStatus.installed && runtimeStatus.runnable + ? "CLI runtime detected and healthy" + : runtimeStatus.installed + ? `CLI found but not runnable${runtimeStatus.reason ? ` (${runtimeStatus.reason})` : ""}` + : "CLI runtime not detected"} +

+ {runtimeStatus.commandPath && ( +

+ Binary:{" "} + + {runtimeStatus.commandPath} + +

+ )} + {runtimeStatus.configPath && ( +

+ Config path:{" "} + + {runtimeStatus.configPath} + +

+ )} +
+
+ )} + {!checkingRuntime && runtimeStatus?.error && ( +
+ error +

+ Failed to check runtime status. +

+
+ )} + {renderNotes()} + {canShowGuide() && + tool.guideSteps.map((item) => ( +
+
+ {item.step} +
+
+

{item.title}

+ {item.desc &&

{item.desc}

} + {item.type === "apiKeySelector" && renderApiKeySelector()} + {item.type === "modelSelector" && renderModelSelector()} + {item.value && ( +
+ + {replaceVars(item.value)} + + {item.copyable && ( + + )} +
+ )} +
+
+ ))} + + {canShowGuide() && tool.codeBlock && ( +
+
+ + {tool.codeBlock.language} + + +
+
+              
+                {replaceVars(tool.codeBlock.code)}
+              
+            
+
+ )} + + {/* Save / Action buttons */} + {canShowGuide() && ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} +
+ {supportsDirectSave && ( + + )} + {tool.codeBlock && ( + + )} + {modelValue && ( + + + check_circle + + Selection saved + + )} +
+
+ )} +
+ ); + }; + + const renderIcon = () => { + if (tool.image) { + return ( + {tool.name} { + e.target.style.display = "none"; + }} + /> + ); + } + if (tool.icon) { + return ( + + {tool.icon} + + ); + } + return ( + {tool.name} { + e.target.style.display = "none"; + }} + /> + ); + }; + + return ( + +
+
+
+ {renderIcon()} +
+
+
+

{tool.name}

+ {runtimeStatus && !runtimeStatus.error && ( + + {runtimeStatus.reason === "not_required" + ? "Guide" + : runtimeStatus.installed && runtimeStatus.runnable + ? "Detected" + : "Not ready"} + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded &&
{renderGuideSteps()}
} + + setShowModelModal(false)} + onSelect={handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title="Select Model" + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js new file mode 100644 index 0000000000..f4c629b823 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -0,0 +1,539 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function DroidToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [droidStatus, setDroidStatus] = useState(null); + const [checkingDroid, setCheckingDroid] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(droidStatus?.installed && droidStatus?.runnable); + + const getConfigStatus = () => { + if (!cliReady) return null; + const currentConfig = droidStatus.settings?.customModels?.find( + (m) => m.id === "custom:OmniRoute-0" + ); + if (!currentConfig) return "not_configured"; + const localMatch = + currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !droidStatus) { + checkDroidStatus(); + fetchModelAliases(); + fetchBackups(); + } + }, [isExpanded, droidStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (droidStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const customModel = droidStatus.settings?.customModels?.find( + (m) => m.id === "custom:OmniRoute-0" + ); + if (customModel) { + if (customModel.model) setSelectedModel(customModel.model); + if (customModel.apiKey && apiKeys?.some((k) => k.key === customModel.apiKey)) { + setSelectedApiKey(customModel.apiKey); + } + } + } + }, [droidStatus, apiKeys]); + + const checkDroidStatus = async () => { + setCheckingDroid(true); + try { + const res = await fetch("/api/cli-tools/droid-settings"); + const data = await res.json(); + setDroidStatus(data); + } catch (error) { + setDroidStatus({ installed: false, error: error.message }); + } finally { + setCheckingDroid(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_omniroute" : null); + + const res = await fetch("/api/cli-tools/droid-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkDroidStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSelectedApiKey(""); + checkDroidStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + // ── Backups ── + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?tool=droid"); + const data = await res.json(); + if (res.ok) setBackups(data.backups || []); + } catch (error) { + console.log("Error fetching backups:", error); + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "droid", backupId }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored!" }); + checkDroidStatus(); + fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed to restore" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoringBackup(null); + } + }; + + const getManualConfigs = () => { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : ""; + + const settingsContent = { + customModels: [ + { + model: selectedModel || "provider/model-id", + id: "custom:OmniRoute-0", + index: 0, + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + displayName: selectedModel || "provider/model-id", + maxOutputTokens: 131072, + noImageSupport: false, + provider: "openai", + }, + ], + }; + + const platform = typeof navigator !== "undefined" && navigator.platform; + const isWindows = platform?.toLowerCase().includes("win"); + const settingsPath = isWindows + ? "%USERPROFILE%\\.factory\\settings.json" + : "~/.factory/settings.json"; + + return [ + { + filename: settingsPath, + content: JSON.stringify(settingsContent, null, 2), + }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { + e.target.style.display = "none"; + }} + /> +
+
+
+

{tool.name}

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

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingDroid && ( +
+ progress_activity + Checking Factory Droid CLI... +
+ )} + + {!checkingDroid && droidStatus && !cliReady && ( +
+ warning +
+

+ {droidStatus.installed + ? "Factory Droid CLI not runnable" + : "Factory Droid CLI not installed"} +

+

+ {droidStatus.installed + ? `Factory Droid CLI was found but failed runtime healthcheck${droidStatus.reason ? ` (${droidStatus.reason})` : ""}.` + : "Please install Factory Droid CLI to use this feature."} +

+
+
+ )} + + {!checkingDroid && cliReady && ( + <> +
+ {/* Current Base URL */} + {droidStatus?.settings?.customModels?.find((m) => m.id === "custom:OmniRoute-0") + ?.baseUrl && ( +
+ + Current + + + arrow_forward + + + { + droidStatus.settings.customModels.find((m) => m.id === "custom:OmniRoute-0") + .baseUrl + } + +
+ )} + + {/* Base URL */} +
+ + Base URL + + + arrow_forward + + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled + ? "No API keys - Create one in Keys page" + : "sk_omniroute (default)"} + + )} +
+ + {/* Model */} +
+ + Model + + + arrow_forward + + setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {selectedModel && ( + + )} +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + + +
+ +
+ + {showBackups && ( +
+

+ history + Config Backups +

+ {backups.length === 0 ? ( +

+ No backups yet. Backups are created automatically before each Apply or Reset. +

+ ) : ( +
+ {backups.map((b) => ( +
+ + description + + + {b.id} + + + {new Date(b.createdAt).toLocaleString()} + + +
+ ))} +
+ )} +
+ )} + + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Factory Droid" + /> + + setShowManualConfigModal(false)} + title="Factory Droid - Manual Configuration" + configs={getManualConfigs()} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js new file mode 100644 index 0000000000..14478ed8b8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js @@ -0,0 +1,472 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function KiloToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [kiloStatus, setKiloStatus] = useState(null); + const [checkingKilo, setCheckingKilo] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(kiloStatus?.installed && kiloStatus?.runnable); + + const getConfigStatus = () => { + if (!cliReady) return null; + if (!kiloStatus.hasOmniRoute) return "not_configured"; + return "configured"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !kiloStatus) { + checkKiloStatus(); + fetchModelAliases(); + fetchBackups(); + } + }, [isExpanded, kiloStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + if (res.ok) { + const data = await res.json(); + setModelAliases(data.aliases || {}); + } + } catch { + /* ignore */ + } + }; + + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?toolId=kilo"); + if (res.ok) { + const data = await res.json(); + setBackups(data.backups || []); + } + } catch { + /* ignore */ + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ toolId: "kilo", backupId }), + }); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored! Reloading status..." }); + await checkKiloStatus(); + await fetchBackups(); + } else { + const data = await res.json(); + setMessage({ type: "error", text: data.error || "Failed to restore backup" }); + } + } catch (e) { + setMessage({ type: "error", text: e.message }); + } finally { + setRestoringBackup(null); + } + }; + + const checkKiloStatus = async () => { + setCheckingKilo(true); + try { + const res = await fetch("/api/cli-tools/kilo-settings"); + const data = await res.json(); + setKiloStatus(data); + } catch (error) { + setKiloStatus({ error: error.message }); + } finally { + setCheckingKilo(false); + } + }; + + const getEffectiveBaseUrl = () => { + if (customBaseUrl) return customBaseUrl; + return baseUrl || "http://localhost:20128"; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const effectiveBaseUrl = getEffectiveBaseUrl(); + const normalizedBaseUrl = effectiveBaseUrl.endsWith("/v1") + ? effectiveBaseUrl + : `${effectiveBaseUrl}/v1`; + + const res = await fetch("/api/cli-tools/kilo-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: normalizedBaseUrl, + apiKey: selectedApiKey || "sk_omniroute", + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Applied!" }); + await checkKiloStatus(); + await fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: data.message || "Reset!" }); + setSelectedModel(""); + hasInitializedModel.current = false; + await checkKiloStatus(); + await fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleSelectModel = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const handleManualConfig = (config) => { + if (config.model) setSelectedModel(config.model); + if (config.apiKey) setSelectedApiKey(config.apiKey); + if (config.baseUrl) setCustomBaseUrl(config.baseUrl); + setShowManualConfigModal(false); + }; + + const renderStatusBadge = () => { + if (!cliReady) return null; + const badges = { + configured: { + class: "bg-green-500/10 text-green-600 dark:text-green-400", + text: "Connected", + }, + not_configured: { + class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + text: "Not configured", + }, + }; + const badge = badges[configStatus]; + if (!badge) return null; + return ( + + {badge.text} + + ); + }; + + return ( + +
+
+
+ {tool.image ? ( + {tool.name} { + e.target.style.display = "none"; + }} + /> + ) : ( + + terminal + + )} +
+
+
+

{tool.name}

+ {renderStatusBadge()} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingKilo && ( +
+ + progress_activity + + Checking Kilo Code CLI... +
+ )} + + {kiloStatus && !checkingKilo && ( +
+ {/* Runtime status */} +
+ + {cliReady ? "check_circle" : "warning"} + +
+

+ {cliReady + ? "Kilo Code CLI detected and ready" + : kiloStatus.installed + ? "Kilo Code CLI installed but not runnable" + : "Kilo Code CLI not detected"} +

+ {kiloStatus.commandPath && ( +

+ Binary:{" "} + + {kiloStatus.commandPath} + +

+ )} + {kiloStatus.authPath && ( +

+ Auth:{" "} + + {kiloStatus.authPath} + +

+ )} +
+
+ + {cliReady && ( + <> + {/* Current config info */} + {configStatus === "configured" && ( +
+ + check_circle + +
+

+ OmniRoute is configured as OpenAI-compatible provider +

+

+ Providers: {kiloStatus.settings?.auth?.join(", ") || "—"} +

+
+
+ )} + + {/* Model selection */} +
+ +
+ setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + +
+
+ + {/* API Key selection */} +
+ + {apiKeys && apiKeys.length > 0 ? ( + + ) : ( +

+ {cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"} +

+ )} +
+ + {/* Action buttons */} +
+ + {configStatus === "configured" && ( + + )} +
+ + {/* Message */} + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + {/* Backups section */} +
+ + {showBackups && backups.length > 0 && ( +
+ {backups.map((b) => ( +
+
+ {b.originalFile} + + {new Date(b.createdAt).toLocaleString()} + +
+ +
+ ))} +
+ )} + {showBackups && backups.length === 0 && ( +

No backups available.

+ )} +
+ + )} +
+ )} +
+ )} + + setModalOpen(false)} + onSelect={handleSelectModel} + selectedModel={selectedModel} + activeProviders={activeProviders} + title="Select Model for Kilo Code" + /> + setShowManualConfigModal(false)} + onApply={handleManualConfig} + currentConfig={{ + model: selectedModel, + apiKey: selectedApiKey, + baseUrl: customBaseUrl || baseUrl, + }} + title="Kilo Code Manual Configuration" + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js new file mode 100644 index 0000000000..917ea01acc --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -0,0 +1,539 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function OpenClawToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, +}) { + const [openclawStatus, setOpenclawStatus] = useState(null); + const [checkingOpenclaw, setCheckingOpenclaw] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + // Backups state + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + const cliReady = !!(openclawStatus?.installed && openclawStatus?.runnable); + + const getConfigStatus = () => { + if (!cliReady) return null; + const currentProvider = openclawStatus.settings?.models?.providers?.["omniroute"]; + if (!currentProvider) return "not_configured"; + const localMatch = + currentProvider.baseUrl?.includes("localhost") || + currentProvider.baseUrl?.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentProvider.baseUrl?.startsWith(CLOUD_URL); + if (localMatch || cloudMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (isExpanded && !openclawStatus) { + checkOpenclawStatus(); + fetchModelAliases(); + fetchBackups(); + } + }, [isExpanded, openclawStatus]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (openclawStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const provider = openclawStatus.settings?.models?.providers?.["omniroute"]; + if (provider) { + const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary; + if (primaryModel) { + const modelId = primaryModel.replace("omniroute/", ""); + setSelectedModel(modelId); + } + if (provider.apiKey && apiKeys?.some((k) => k.key === provider.apiKey)) { + setSelectedApiKey(provider.apiKey); + } + } + } + }, [openclawStatus, apiKeys]); + + const checkOpenclawStatus = async () => { + setCheckingOpenclaw(true); + try { + const res = await fetch("/api/cli-tools/openclaw-settings"); + const data = await res.json(); + setOpenclawStatus(data); + } catch (error) { + setOpenclawStatus({ installed: false, error: error.message }); + } finally { + setCheckingOpenclaw(false); + } + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getDisplayUrl = () => { + const url = customBaseUrl || baseUrl; + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_omniroute" : null); + + const res = await fetch("/api/cli-tools/openclaw-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkOpenclawStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + setSelectedApiKey(""); + checkOpenclawStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + // ── Backups ── + const fetchBackups = async () => { + try { + const res = await fetch("/api/cli-tools/backups?tool=openclaw"); + const data = await res.json(); + if (res.ok) setBackups(data.backups || []); + } catch (error) { + console.log("Error fetching backups:", error); + } + }; + + const handleRestoreBackup = async (backupId) => { + setRestoringBackup(backupId); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: "openclaw", backupId }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Backup restored!" }); + checkOpenclawStatus(); + fetchBackups(); + } else { + setMessage({ type: "error", text: data.error || "Failed to restore" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoringBackup(null); + } + }; + + const getManualConfigs = () => { + const keyToUse = + selectedApiKey && selectedApiKey.trim() + ? selectedApiKey + : !cloudEnabled + ? "sk_omniroute" + : ""; + + const settingsContent = { + agents: { + defaults: { + model: { + primary: `omniroute/${selectedModel || "provider/model-id"}`, + }, + }, + }, + models: { + providers: { + omniroute: { + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + api: "openai-completions", + models: [ + { + id: selectedModel || "provider/model-id", + name: (selectedModel || "provider/model-id").split("/").pop(), + }, + ], + }, + }, + }, + }; + + return [ + { + filename: "~/.openclaw/openclaw.json", + content: JSON.stringify(settingsContent, null, 2), + }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { + e.target.style.display = "none"; + }} + /> +
+
+
+

{tool.name}

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

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checkingOpenclaw && ( +
+ progress_activity + Checking Open Claw CLI... +
+ )} + + {!checkingOpenclaw && openclawStatus && !cliReady && ( +
+ warning +
+

+ {openclawStatus.installed + ? "Open Claw CLI not runnable" + : "Open Claw CLI not installed"} +

+

+ {openclawStatus.installed + ? `Open Claw CLI was found but failed runtime healthcheck${openclawStatus.reason ? ` (${openclawStatus.reason})` : ""}.` + : "Please install Open Claw CLI to use this feature."} +

+
+
+ )} + + {!checkingOpenclaw && cliReady && ( + <> +
+ {/* Current Base URL */} + {openclawStatus?.settings?.models?.providers?.["omniroute"]?.baseUrl && ( +
+ + Current + + + arrow_forward + + + {openclawStatus.settings.models.providers["omniroute"].baseUrl} + +
+ )} + + {/* Base URL */} +
+ + Base URL + + + arrow_forward + + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled + ? "No API keys - Create one in Keys page" + : "sk_omniroute (default)"} + + )} +
+ + {/* Model */} +
+ + Model + + + arrow_forward + + setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {selectedModel && ( + + )} +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + + +
+ +
+ + {showBackups && ( +
+

+ history + Config Backups +

+ {backups.length === 0 ? ( +

+ No backups yet. Backups are created automatically before each Apply or Reset. +

+ ) : ( +
+ {backups.map((b) => ( +
+ + description + + + {b.id} + + + {new Date(b.createdAt).toLocaleString()} + + +
+ ))} +
+ )} +
+ )} + + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Open Claw" + /> + + setShowManualConfigModal(false)} + title="Open Claw - Manual Configuration" + configs={getManualConfigs()} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js new file mode 100644 index 0000000000..3cffbb3a2f --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -0,0 +1,8 @@ +export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as CodexToolCard } from "./CodexToolCard"; +export { default as DroidToolCard } from "./DroidToolCard"; +export { default as OpenClawToolCard } from "./OpenClawToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; +export { default as DefaultToolCard } from "./DefaultToolCard"; +export { default as AntigravityToolCard } from "./AntigravityToolCard"; diff --git a/src/app/(dashboard)/dashboard/cli-tools/page.js b/src/app/(dashboard)/dashboard/cli-tools/page.js new file mode 100644 index 0000000000..24f6030ae8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/page.js @@ -0,0 +1,7 @@ +import { getMachineId } from "@/shared/utils/machine"; +import CLIToolsPageClient from "./CLIToolsPageClient"; + +export default async function CLIToolsPage() { + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js new file mode 100644 index 0000000000..3b25f25b75 --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -0,0 +1,1090 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + Card, + Button, + Modal, + Input, + CardSkeleton, + ModelSelectModal, + ProxyConfigModal, +} from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +// Validate combo name: letters, numbers, -, _, /, . +const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/; + +// ───────────────────────────────────────────── +// Helper: normalize model entry (legacy string ↔ new object) +// ───────────────────────────────────────────── +function normalizeModelEntry(entry) { + if (typeof entry === "string") return { model: entry, weight: 0 }; + return { model: entry.model, weight: entry.weight || 0 }; +} + +function getModelString(entry) { + return typeof entry === "string" ? entry : entry.model; +} + +// ───────────────────────────────────────────── +// Main Page +// ───────────────────────────────────────────── +export default function CombosPage() { + const [combos, setCombos] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingCombo, setEditingCombo] = useState(null); + const [activeProviders, setActiveProviders] = useState([]); + const [metrics, setMetrics] = useState({}); + const [testResults, setTestResults] = useState(null); + const [testingCombo, setTestingCombo] = useState(null); + const { copied, copy } = useCopyToClipboard(); + const [proxyTargetCombo, setProxyTargetCombo] = useState(null); + const [proxyConfig, setProxyConfig] = useState(null); + + useEffect(() => { + fetchData(); + fetch("/api/settings/proxy") + .then((r) => (r.ok ? r.json() : null)) + .then((c) => setProxyConfig(c)) + .catch(() => {}); + }, []); + + const fetchData = async () => { + try { + const [combosRes, providersRes, metricsRes] = await Promise.all([ + fetch("/api/combos"), + fetch("/api/providers"), + fetch("/api/combos/metrics"), + ]); + const combosData = await combosRes.json(); + const providersData = await providersRes.json(); + const metricsData = await metricsRes.json(); + + if (combosRes.ok) setCombos(combosData.combos || []); + if (providersRes.ok) { + const active = (providersData.connections || []).filter( + (c) => c.testStatus === "active" || c.testStatus === "success" + ); + setActiveProviders(active); + } + if (metricsRes.ok) setMetrics(metricsData.metrics || {}); + } catch (error) { + console.log("Error fetching data:", error); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (data) => { + try { + const res = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + await fetchData(); + setShowCreateModal(false); + } else { + const err = await res.json(); + alert(err.error?.message || err.error || "Failed to create combo"); + } + } catch (error) { + console.log("Error creating combo:", error); + } + }; + + const handleUpdate = async (id, data) => { + try { + const res = await fetch(`/api/combos/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + await fetchData(); + setEditingCombo(null); + } else { + const err = await res.json(); + alert(err.error?.message || err.error || "Failed to update combo"); + } + } catch (error) { + console.log("Error updating combo:", error); + } + }; + + const handleDelete = async (id) => { + if (!confirm("Delete this combo?")) return; + try { + const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); + if (res.ok) { + setCombos(combos.filter((c) => c.id !== id)); + } + } catch (error) { + console.log("Error deleting combo:", error); + } + }; + + const handleDuplicate = async (combo) => { + const baseName = combo.name.replace(/-copy(-\d+)?$/, ""); + const existingNames = combos.map((c) => c.name); + let newName = `${baseName}-copy`; + let counter = 1; + while (existingNames.includes(newName)) { + counter++; + newName = `${baseName}-copy-${counter}`; + } + + const data = { + name: newName, + models: combo.models, + strategy: combo.strategy || "priority", + config: combo.config || {}, + }; + + await handleCreate(data); + }; + + const handleTestCombo = async (combo) => { + setTestingCombo(combo.name); + setTestResults(null); + try { + const res = await fetch("/api/combos/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboName: combo.name }), + }); + const data = await res.json(); + setTestResults(data); + } catch (error) { + setTestResults({ error: "Test request failed" }); + } + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Combos

+

+ Create model combos with weighted routing and fallback support +

+
+ +
+ + {/* Combos List */} + {combos.length === 0 ? ( + +
+
+ layers +
+

No combos yet

+

+ Create model combos with weighted routing and fallback support +

+ +
+
+ ) : ( +
+ {combos.map((combo) => ( + setEditingCombo(combo)} + onDelete={() => handleDelete(combo.id)} + onDuplicate={() => handleDuplicate(combo)} + onTest={() => handleTestCombo(combo)} + testing={testingCombo === combo.name} + onProxy={() => setProxyTargetCombo(combo)} + hasProxy={!!proxyConfig?.combos?.[combo.id]} + /> + ))} +
+ )} + + {/* Test Results Modal */} + {testResults && ( + { + setTestResults(null); + setTestingCombo(null); + }} + title={`Test Results — ${testingCombo}`} + > + + + )} + + {/* Create Modal */} + setShowCreateModal(false)} + onSave={handleCreate} + activeProviders={activeProviders} + /> + + {/* Edit Modal */} + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + activeProviders={activeProviders} + /> + + {/* Proxy Config Modal */} + {proxyTargetCombo && ( + setProxyTargetCombo(null)} + level="combo" + levelId={proxyTargetCombo.id} + levelLabel={proxyTargetCombo.name} + /> + )} +
+ ); +} + +// ───────────────────────────────────────────── +// Combo Card +// ───────────────────────────────────────────── +function ComboCard({ + combo, + metrics, + copied, + onCopy, + onEdit, + onDelete, + onDuplicate, + onTest, + testing, + onProxy, + hasProxy, +}) { + const strategy = combo.strategy || "priority"; + const models = combo.models || []; + + return ( + +
+
+ {/* Icon */} +
+ layers +
+
+ {/* Name + Strategy Badge + Copy */} +
+ {combo.name} + + {strategy} + + {hasProxy && ( + + vpn_lock + proxy + + )} + +
+ + {/* Model tags with weights */} +
+ {models.length === 0 ? ( + No models + ) : ( + models.slice(0, 3).map((entry, index) => { + const { model, weight } = normalizeModelEntry(entry); + return ( + + {model} + {strategy === "weighted" && weight > 0 ? ` (${weight}%)` : ""} + + ); + }) + )} + {models.length > 3 && ( + +{models.length - 3} more + )} +
+ + {/* Metrics row */} + {metrics && ( +
+ + {metrics.totalSuccesses}/ + {metrics.totalRequests} reqs + + {metrics.successRate}% success + ~{metrics.avgLatencyMs}ms + {metrics.fallbackRate > 0 && ( + + {metrics.fallbackRate}% fallback + + )} +
+ )} +
+
+ + {/* Actions */} +
+ + + + + +
+
+
+ ); +} + +// ───────────────────────────────────────────── +// Test Results View +// ───────────────────────────────────────────── +function TestResultsView({ results }) { + if (results.error) { + return ( +
+ error + {typeof results.error === "string" ? results.error : JSON.stringify(results.error)} +
+ ); + } + + return ( +
+ {results.resolvedBy && ( +
+ + check_circle + + + Resolved by:{" "} + + {results.resolvedBy} + + +
+ )} + {results.results?.map((r, i) => ( +
+ + {r.status === "ok" ? "check_circle" : r.status === "skipped" ? "skip_next" : "error"} + + {r.model} + {r.latencyMs !== undefined && {r.latencyMs}ms} + + {r.status} + +
+ ))} +
+ ); +} + +// ───────────────────────────────────────────── +// Combo Form Modal +// ───────────────────────────────────────────── +function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { + const [name, setName] = useState(combo?.name || ""); + const [models, setModels] = useState(() => { + return (combo?.models || []).map((m) => normalizeModelEntry(m)); + }); + const [strategy, setStrategy] = useState(combo?.strategy || "priority"); + const [showModelSelect, setShowModelSelect] = useState(false); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + const [providerNodes, setProviderNodes] = useState([]); + const [showAdvanced, setShowAdvanced] = useState(false); + const [config, setConfig] = useState(combo?.config || {}); + + // DnD state + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const fetchModalData = async () => { + try { + const [aliasesRes, nodesRes] = await Promise.all([ + fetch("/api/models/alias"), + fetch("/api/provider-nodes"), + ]); + + if (!aliasesRes.ok || !nodesRes.ok) { + throw new Error( + `Failed to fetch data: aliases=${aliasesRes.status}, nodes=${nodesRes.status}` + ); + } + + const [aliasesData, nodesData] = await Promise.all([aliasesRes.json(), nodesRes.json()]); + setModelAliases(aliasesData.aliases || {}); + setProviderNodes(nodesData.nodes || []); + } catch (error) { + console.error("Error fetching modal data:", error); + } + }; + + useEffect(() => { + if (isOpen) fetchModalData(); + }, [isOpen]); + + const validateName = (value) => { + if (!value.trim()) { + setNameError("Name is required"); + return false; + } + if (!VALID_NAME_REGEX.test(value)) { + setNameError("Only letters, numbers, -, _, / and . allowed"); + return false; + } + setNameError(""); + return true; + }; + + const handleNameChange = (e) => { + const value = e.target.value; + setName(value); + if (value) validateName(value); + else setNameError(""); + }; + + const handleAddModel = (model) => { + if (!models.find((m) => m.model === model.value)) { + setModels([...models, { model: model.value, weight: 0 }]); + } + }; + + const handleRemoveModel = (index) => { + setModels(models.filter((_, i) => i !== index)); + }; + + const handleWeightChange = (index, weight) => { + const newModels = [...models]; + newModels[index] = { + ...newModels[index], + weight: Math.max(0, Math.min(100, Number(weight) || 0)), + }; + setModels(newModels); + }; + + const handleAutoBalance = () => { + const count = models.length; + if (count === 0) return; + const weight = Math.floor(100 / count); + const remainder = 100 - weight * count; + setModels( + models.map((m, i) => ({ + ...m, + weight: weight + (i === 0 ? remainder : 0), + })) + ); + }; + + // Format model display name with readable provider name + const formatModelDisplay = useCallback( + (modelValue) => { + const parts = modelValue.split("/"); + if (parts.length !== 2) return modelValue; + + const [providerId, modelId] = parts; + const matchedNode = providerNodes.find((node) => node.id === providerId); + + if (matchedNode) { + return `${matchedNode.name}/${modelId}`; + } + + return modelValue; + }, + [providerNodes] + ); + + const handleMoveUp = (index) => { + if (index === 0) return; + const newModels = [...models]; + [newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]]; + setModels(newModels); + }; + + const handleMoveDown = (index) => { + if (index === models.length - 1) return; + const newModels = [...models]; + [newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]]; + setModels(newModels); + }; + + // Drag and Drop handlers + const handleDragStart = (e, index) => { + setDragIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", index.toString()); + // Make drag image slightly transparent + if (e.target) { + setTimeout(() => (e.target.style.opacity = "0.5"), 0); + } + }; + + const handleDragEnd = (e) => { + if (e.target) e.target.style.opacity = "1"; + setDragIndex(null); + setDragOverIndex(null); + }; + + const handleDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + }; + + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + const fromIndex = dragIndex; + if (fromIndex === null || fromIndex === dropIndex) return; + + const newModels = [...models]; + const [moved] = newModels.splice(fromIndex, 1); + newModels.splice(dropIndex, 0, moved); + setModels(newModels); + setDragIndex(null); + setDragOverIndex(null); + }; + + const handleSave = async () => { + if (!validateName(name)) return; + setSaving(true); + + const saveData = { + name: name.trim(), + models: strategy === "weighted" ? models : models.map((m) => m.model), + strategy, + }; + + // Include config only if any values are set + const configToSave = { ...config }; + // Add round-robin specific fields to config + if (strategy === "round-robin") { + if (config.concurrencyPerModel !== undefined) + configToSave.concurrencyPerModel = config.concurrencyPerModel; + if (config.queueTimeoutMs !== undefined) configToSave.queueTimeoutMs = config.queueTimeoutMs; + } + if (Object.keys(configToSave).length > 0) { + saveData.config = configToSave; + } + + await onSave(saveData); + setSaving(false); + }; + + const isEdit = !!combo; + + return ( + <> + +
+ {/* Name */} +
+ +

+ Letters, numbers, -, _, / and . allowed +

+
+ + {/* Strategy Toggle */} +
+ +
+ + + +
+

+ {strategy === "priority" + ? "Sequential fallback: tries model 1 first, then 2, etc." + : strategy === "weighted" + ? "Distributes traffic by weight percentage with fallback" + : "Circular distribution: each request goes to the next model in rotation"} +

+
+ + {/* Models */} +
+
+ + {strategy === "weighted" && models.length > 1 && ( + + )} +
+ + {models.length === 0 ? ( +
+ + layers + +

No models added yet

+
+ ) : ( +
+ {models.map((entry, index) => ( +
handleDragStart(e, index)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + className={`group/item flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-all cursor-grab active:cursor-grabbing ${ + dragOverIndex === index && dragIndex !== index + ? "bg-primary/10 border border-primary/30" + : "bg-black/[0.02] dark:bg-white/[0.02] hover:bg-black/[0.04] dark:hover:bg-white/[0.04] border border-transparent" + } ${dragIndex === index ? "opacity-50" : ""}`} + > + {/* Drag handle */} + + drag_indicator + + + {/* Index badge */} + + {index + 1} + + + {/* Model display */} +
+ {formatModelDisplay(entry.model)} +
+ + {/* Weight input (weighted mode only) */} + {strategy === "weighted" && ( +
+ handleWeightChange(index, e.target.value)} + className="w-10 text-[11px] text-center py-0.5 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> + % +
+ )} + + {/* Priority arrows (priority mode) */} + {strategy === "priority" && ( +
+ + +
+ )} + + {/* Remove */} + +
+ ))} +
+ )} + + {/* Weight total indicator */} + {strategy === "weighted" && models.length > 0 && } + + {/* Add Model button */} + +
+ + {/* Advanced Config Toggle */} + + + {showAdvanced && ( +
+
+
+ + + setConfig({ + ...config, + maxRetries: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig({ + ...config, + retryDelayMs: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig({ + ...config, + timeoutMs: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + setConfig({ ...config, healthCheckEnabled: e.target.checked })} + className="accent-primary" + /> +
+
+ {strategy === "round-robin" && ( +
+
+ + + setConfig({ + ...config, + concurrencyPerModel: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ + + setConfig({ + ...config, + queueTimeoutMs: e.target.value ? Number(e.target.value) : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
+
+ )} +

+ Leave empty to use global defaults. These override per-provider settings. +

+
+ )} + + {/* Actions */} +
+ + +
+
+
+ + {/* Model Select Modal */} + setShowModelSelect(false)} + onSelect={handleAddModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model to Combo" + /> + + ); +} + +// ───────────────────────────────────────────── +// Weight Total Bar +// ───────────────────────────────────────────── +function WeightTotalBar({ models }) { + const total = models.reduce((sum, m) => sum + (m.weight || 0), 0); + const isValid = total === 100; + const colors = [ + "bg-blue-500", + "bg-emerald-500", + "bg-amber-500", + "bg-purple-500", + "bg-rose-500", + "bg-cyan-500", + "bg-orange-500", + "bg-indigo-500", + ]; + + return ( +
+ {/* Visual bar */} +
+ {models.map((m, i) => { + if (!m.weight) return null; + return ( +
+ ); + })} +
+
+
+ {models.map( + (m, i) => + m.weight > 0 && ( + + + {m.weight}% + + ) + )} +
+ 100 ? "text-red-500" : "text-amber-500" + }`} + > + {total}%{!isValid && total > 0 && " ≠ 100%"} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js new file mode 100644 index 0000000000..d66069b1f7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -0,0 +1,1116 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import PropTypes from "prop-types"; +import Image from "next/image"; +import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const CLOUD_ACTION_TIMEOUT_MS = 15000; + +export default function APIPageClient({ machineId }) { + const [keys, setKeys] = useState([]); + const [providerConnections, setProviderConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [createdKey, setCreatedKey] = useState(null); + + // Endpoints / models state + const [allModels, setAllModels] = useState([]); + const [expandedEndpoint, setExpandedEndpoint] = useState(null); + + // Cloud sync state + const [cloudEnabled, setCloudEnabled] = useState(false); + const [showCloudModal, setShowCloudModal] = useState(false); + const [showDisableModal, setShowDisableModal] = useState(false); + const [cloudSyncing, setCloudSyncing] = useState(false); + const [cloudStatus, setCloudStatus] = useState(null); + const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "" + const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup + + const { copied, copy } = useCopyToClipboard(); + + useEffect(() => { + fetchData(); + loadCloudSettings(); + fetchModels(); + }, []); + + const fetchModels = async () => { + try { + const res = await fetch("/v1/models"); + if (res.ok) { + const data = await res.json(); + setAllModels(data.data || []); + } + } catch (e) { + console.log("Error fetching models:", e); + } + }; + + // Categorize models by endpoint type + const endpointData = useMemo(() => { + const chat = allModels.filter((m) => !m.type); + const embeddings = allModels.filter((m) => m.type === "embedding"); + const images = allModels.filter((m) => m.type === "image"); + return { chat, embeddings, images }; + }, [allModels]); + + const providerStats = useMemo(() => { + return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => { + const connections = providerConnections.filter((conn) => conn.provider === providerId); + const connected = connections.filter( + (conn) => + conn.isActive !== false && + (conn.testStatus === "active" || + conn.testStatus === "success" || + conn.testStatus === "unknown") + ).length; + const errors = connections.filter( + (conn) => + conn.isActive !== false && + (conn.testStatus === "error" || + conn.testStatus === "expired" || + conn.testStatus === "unavailable") + ).length; + + return { + id: providerId, + provider: providerInfo, + total: connections.length, + connected, + errors, + }; + }); + }, [providerConnections]); + + const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch("/api/sync/cloud", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, status: res.status, data }; + } catch (error) { + if (error?.name === "AbortError") { + return { ok: false, status: 408, data: { error: "Cloud request timeout" } }; + } + return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } }; + } finally { + clearTimeout(timeoutId); + } + }; + + const loadCloudSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setCloudEnabled(data.cloudEnabled || false); + } + } catch (error) { + console.log("Error loading cloud settings:", error); + } + }; + + const fetchData = async () => { + try { + const [keysRes, providersRes] = await Promise.all([ + fetch("/api/keys"), + fetch("/api/providers"), + ]); + + const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]); + + if (keysRes.ok) { + setKeys(keysData.keys || []); + } + + if (providersRes.ok) { + setProviderConnections(providersData.connections || []); + } + } catch (error) { + console.log("Error fetching data:", error); + } finally { + setLoading(false); + } + }; + + const handleCloudToggle = (checked) => { + if (checked) { + setShowCloudModal(true); + } else { + setShowDisableModal(true); + } + }; + + const handleEnableCloud = async () => { + setCloudSyncing(true); + setSyncStep("syncing"); + try { + const { ok, data } = await postCloudAction("enable"); + if (ok) { + setSyncStep("verifying"); + + if (data.verified) { + setCloudEnabled(true); + setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" }); + setShowCloudModal(false); + } else { + setCloudEnabled(true); + setCloudStatus({ + type: "warning", + message: data.verifyError || "Connected but verification failed", + }); + setShowCloudModal(false); + } + + // Refresh keys list if new key was created + if (data.createdKey) { + await fetchData(); + } + } else { + setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" }); + } + } catch (error) { + setCloudStatus({ type: "error", message: error.message }); + } finally { + setCloudSyncing(false); + setSyncStep(""); + } + }; + + const handleConfirmDisable = async () => { + setCloudSyncing(true); + setSyncStep("syncing"); + + try { + // Step 1: Sync latest data from cloud + await postCloudAction("sync"); + + setSyncStep("disabling"); + + // Step 2: Disable cloud + const { ok, data } = await postCloudAction("disable"); + + if (ok) { + setCloudEnabled(false); + setCloudStatus({ type: "success", message: "Cloud disabled" }); + setShowDisableModal(false); + } else { + setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" }); + } + } catch (error) { + console.log("Error disabling cloud:", error); + setCloudStatus({ type: "error", message: "Failed to disable cloud" }); + } finally { + setCloudSyncing(false); + setSyncStep(""); + } + }; + + const handleSyncCloud = async () => { + if (!cloudEnabled) return; + + setCloudSyncing(true); + try { + const { ok, data } = await postCloudAction("sync"); + if (ok) { + setCloudStatus({ type: "success", message: "Synced successfully" }); + } else { + setCloudStatus({ type: "error", message: data.error }); + } + } catch (error) { + setCloudStatus({ type: "error", message: error.message }); + } finally { + setCloudSyncing(false); + } + }; + + const handleCreateKey = async () => { + if (!newKeyName.trim()) return; + + try { + const res = await fetch("/api/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newKeyName }), + }); + const data = await res.json(); + + if (res.ok) { + setCreatedKey(data.key); + await fetchData(); + setNewKeyName(""); + setShowAddModal(false); + } + } catch (error) { + console.log("Error creating key:", error); + } + }; + + const handleDeleteKey = async (id) => { + if (!confirm("Delete this API key?")) return; + + try { + const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); + if (res.ok) { + setKeys(keys.filter((k) => k.id !== id)); + } + } catch (error) { + console.log("Error deleting key:", error); + } + }; + + const [baseUrl, setBaseUrl] = useState("/v1"); + const cloudEndpointNew = `${CLOUD_URL}/v1`; + + // Hydration fix: Only access window on client side + useEffect(() => { + if (typeof window !== "undefined") { + setBaseUrl(`${window.location.origin}/v1`); + } + }, []); + + if (loading) { + return ( +
+ + +
+ ); + } + + // Use new format endpoint (machineId embedded in key) + const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl; + + const cloudBenefits = [ + { icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" }, + { icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" }, + { icon: "schedule", title: "Always Online", desc: "24/7 availability" }, + { icon: "speed", title: "Global Edge", desc: "Fast worldwide access" }, + ]; + + const quickStartLinks = [ + { label: "Documentation", href: "/docs" }, + { label: "OpenAI API compatibility", href: "/docs#api-reference" }, + { label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" }, + { label: "Report issue", href: "https://github.com/decolua/omniroute/issues", external: true }, + ]; + + return ( +
+ {/* Endpoint Card */} + +
+
+

API Endpoint

+

+ {cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"} +

+ {machineId && ( +

Machine ID: {machineId.slice(0, 8)}...

+ )} +
+
+ {cloudEnabled ? ( + + ) : ( + + )} +
+
+ + {/* Endpoint URL */} +
+ + +
+
+ + {/* Quick Start */} + +
+
+

Quick Start

+

+ First-time setup checklist for API clients and IDE tools. +

+
+ +
    +
  1. + 1. Create API key +

    + Generate one key per environment to isolate usage and revoke safely. +

    +
  2. +
  3. + 2. Connect provider account +

    + Configure providers in Dashboard and validate with Test Connection. +

    +
  4. +
  5. + 3. Use endpoint +

    + Point clients to {currentEndpoint} and send requests to{" "} + /chat/completions. +

    +
  6. +
  7. + 4. Monitor usage +

    + Track requests, tokens, errors, and cost in Usage and Request Logger. +

    +
  8. +
+ + +
+
+ + {/* API Keys */} + +
+

API Keys

+ +
+ + {keys.length === 0 ? ( +
+
+ vpn_key +
+

No API keys yet

+

Create your first API key to get started

+ +
+ ) : ( +
+ {keys.map((key) => ( +
+
+

{key.name}

+
+ {key.key} + +
+

+ Created {new Date(key.createdAt).toLocaleDateString()} +

+
+ +
+ ))} +
+ )} +
+ + {/* Providers Overview */} + +
+
+

Providers Overview

+

+ {providerStats.filter((item) => item.total > 0).length} configured of{" "} + {providerStats.length} available providers +

+
+
+ +
+ {providerStats.map((item) => ( + setSelectedProvider(item)} + /> + ))} +
+
+ + {/* Available Endpoints */} + +
+
+

Available Endpoints

+

{allModels.length} models across 3 endpoints

+
+
+ +
+ {/* Chat Completions */} + setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")} + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> + + {/* Embeddings */} + + setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings") + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> + + {/* Image Generation */} + setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")} + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> +
+
+ + {/* Cloud Proxy Card - Hidden */} + {false && ( + +
+ {/* Header */} +
+
+
+ cloud +
+
+

Cloud Proxy

+

+ {cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"} +

+
+
+
+ {cloudEnabled ? ( + + ) : ( + + )} +
+
+ + {/* Benefits Grid */} +
+ {cloudBenefits.map((benefit) => ( +
+ + {benefit.icon} + +

{benefit.title}

+

{benefit.desc}

+
+ ))} +
+
+
+ )} + + {/* Cloud Enable Modal */} + setShowCloudModal(false)} + > +
+
+

+ What you will get +

+
    +
  • • Access your API from anywhere in the world
  • +
  • • Share endpoint with your team easily
  • +
  • • No need to open ports or configure firewall
  • +
  • • Fast global edge network
  • +
+
+ +
+

Note

+
    +
  • + • Cloud will keep your auth session for 1 day. If not used, it will be automatically + deleted. +
  • +
  • • Cloud is currently unstable with Claude Code OAuth in some cases.
  • +
+
+ + {/* Sync Progress */} + {cloudSyncing && ( +
+ + progress_activity + +
+

+ {syncStep === "syncing" && "Syncing data to cloud..."} + {syncStep === "verifying" && "Verifying connection..."} +

+
+
+ )} + +
+ + +
+
+
+ + {/* Add Key Modal */} + { + setShowAddModal(false); + setNewKeyName(""); + }} + > +
+ setNewKeyName(e.target.value)} + placeholder="Production Key" + /> +
+ + +
+
+
+ + {/* Created Key Modal */} + setCreatedKey(null)}> +
+
+

+ Save this key now! +

+

+ This is the only time you will see this key. Store it securely. +

+
+
+ + +
+ +
+
+ + {/* Disable Cloud Modal */} + !cloudSyncing && setShowDisableModal(false)} + > +
+
+
+ + warning + +
+

Warning

+

+ All auth sessions will be deleted from cloud. +

+
+
+
+ + {/* Sync Progress */} + {cloudSyncing && ( +
+ + progress_activity + +
+

+ {syncStep === "syncing" && "Syncing latest data..."} + {syncStep === "disabling" && "Disabling cloud..."} +

+
+
+ )} + +

Are you sure you want to disable cloud proxy?

+ +
+ + +
+
+
+ {/* Provider Models Popup */} + {selectedProvider && ( + setSelectedProvider(null)} + /> + )} +
+ ); +} + +APIPageClient.propTypes = { + machineId: PropTypes.string.isRequired, +}; + +function ProviderOverviewCard({ item, onClick }) { + const [imgError, setImgError] = useState(false); + + const statusVariant = + item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted"; + + return ( +
e.key === "Enter" && onClick?.()} + > +
+
+ {imgError ? ( + + {item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()} + + ) : ( + {item.provider.name} setImgError(true)} + /> + )} +
+ +
+

{item.provider.name}

+

+ {item.total === 0 + ? "Not configured" + : `${item.connected} active · ${item.errors} error`} +

+
+ + #{item.total} +
+
+ ); +} + +ProviderOverviewCard.propTypes = { + item: PropTypes.shape({ + id: PropTypes.string.isRequired, + provider: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + textIcon: PropTypes.string, + }).isRequired, + total: PropTypes.number.isRequired, + connected: PropTypes.number.isRequired, + errors: PropTypes.number.isRequired, + }).isRequired, + onClick: PropTypes.func, +}; + +// -- Sub-component: Provider Models Modal ------------------------------------------ + +function ProviderModelsModal({ provider, models, copy, copied, onClose }) { + // Get provider alias for matching models + const providerAlias = provider.provider.alias || provider.id; + const providerModels = useMemo(() => { + return models.filter((m) => m.owned_by === providerAlias || m.owned_by === provider.id); + }, [models, providerAlias, provider.id]); + + const chatModels = providerModels.filter((m) => !m.type); + const embeddingModels = providerModels.filter((m) => m.type === "embedding"); + const imageModels = providerModels.filter((m) => m.type === "image"); + + const renderModelGroup = (title, icon, groupModels) => { + if (groupModels.length === 0) return null; + return ( +
+

+ {icon} + {title} ({groupModels.length}) +

+
+ {groupModels.map((m) => { + const copyKey = `modal-${m.id}`; + return ( +
+ {m.id} + {m.custom && ( + + custom + + )} + +
+ ); + })} +
+
+ ); + }; + + return ( + +
+ {providerModels.length === 0 ? ( +

+ No models available for this provider. +

+ ) : ( + <> + {renderModelGroup("Chat", "chat", chatModels)} + {renderModelGroup("Embedding", "data_array", embeddingModels)} + {renderModelGroup("Image", "image", imageModels)} + + )} +
+
+ ); +} + +ProviderModelsModal.propTypes = { + provider: PropTypes.object.isRequired, + models: PropTypes.array.isRequired, + copy: PropTypes.func.isRequired, + copied: PropTypes.string, + onClose: PropTypes.func.isRequired, +}; + +// -- Sub-component: Endpoint Section ------------------------------------------ + +function EndpointSection({ + icon, + iconColor, + iconBg, + title, + path, + description, + models, + expanded, + onToggle, + copy, + copied, + baseUrl, +}) { + const grouped = useMemo(() => { + const map = {}; + for (const m of models) { + const owner = m.owned_by || "unknown"; + if (!map[owner]) map[owner] = []; + map[owner].push(m); + } + return Object.entries(map).sort((a, b) => b[1].length - a[1].length); + }, [models]); + + const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id); + const providerColor = (id) => resolveProvider(id)?.color || "#888"; + const providerName = (id) => resolveProvider(id)?.name || id; + const copyId = `endpoint_${path}`; + + return ( +
+ {/* Header (always visible) */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* Endpoint path + copy */} +
+ + {baseUrl.replace(/\/v1$/, "")} + {path} + + +
+ + {/* Models grouped by provider */} +
+ {grouped.map(([providerId, providerModels]) => ( +
+
+
+ + {providerName(providerId)} + + ({providerModels.length}) +
+
+ {providerModels.map((m) => ( + + {m.root || m.id.split("/").pop()} + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} + +EndpointSection.propTypes = { + icon: PropTypes.string.isRequired, + iconColor: PropTypes.string.isRequired, + iconBg: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + models: PropTypes.array.isRequired, + expanded: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + copy: PropTypes.func.isRequired, + copied: PropTypes.string, + baseUrl: PropTypes.string.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/endpoint/page.js b/src/app/(dashboard)/dashboard/endpoint/page.js new file mode 100644 index 0000000000..96a3e31e01 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/page.js @@ -0,0 +1,7 @@ +import { getMachineId } from "@/shared/utils/machine"; +import EndpointPageClient from "./EndpointPageClient"; + +export default async function EndpointPage() { + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/onboarding/page.js b/src/app/(dashboard)/dashboard/onboarding/page.js new file mode 100644 index 0000000000..d0e8751150 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/page.js @@ -0,0 +1,473 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +const STEPS = [ + { id: "welcome", title: "Welcome", icon: "waving_hand" }, + { id: "security", title: "Security", icon: "lock" }, + { id: "provider", title: "Provider", icon: "dns" }, + { id: "test", title: "Test", icon: "play_circle" }, + { id: "done", title: "Ready!", icon: "check_circle" }, +]; + +const COMMON_PROVIDERS = [ + { id: "openai", name: "OpenAI", color: "#10A37F" }, + { id: "anthropic", name: "Anthropic", color: "#D97757" }, + { id: "google", name: "Google AI", color: "#4285F4" }, + { id: "openrouter", name: "OpenRouter", color: "#6B21A8" }, + { id: "groq", name: "Groq", color: "#F55036" }, + { id: "mistral", name: "Mistral", color: "#FF7000" }, +]; + +export default function OnboardingWizard() { + const router = useRouter(); + const [step, setStep] = useState(0); + const [loading, setLoading] = useState(true); + + // Security step state + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [skipSecurity, setSkipSecurity] = useState(false); + + // Provider step state + const [selectedProvider, setSelectedProvider] = useState(null); + const [providerUrl, setProviderUrl] = useState(""); + const [providerKey, setProviderKey] = useState(""); + const [providerName, setProviderName] = useState(""); + + // Test step state + const [testStatus, setTestStatus] = useState("idle"); // idle, testing, success, error + const [testMessage, setTestMessage] = useState(""); + + // Check if setup is already complete + useEffect(() => { + const checkSetup = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const settings = await res.json(); + if (settings.setupComplete) { + router.replace("/dashboard"); + return; + } + } + } catch { + // Continue with setup + } + setLoading(false); + }; + checkSetup(); + }, [router]); + + const currentStep = STEPS[step]; + const isLastStep = step === STEPS.length - 1; + + const handleNext = () => { + if (step < STEPS.length - 1) setStep(step + 1); + }; + + const handleBack = () => { + if (step > 0) setStep(step - 1); + }; + + const handleSetPassword = async () => { + if (skipSecurity) { + handleNext(); + return; + } + if (password !== confirmPassword) return; + try { + await fetch("/api/settings/require-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireLogin: true, password }), + }); + handleNext(); + } catch { + // Fallback: skip + handleNext(); + } + }; + + const handleAddProvider = async () => { + if (!selectedProvider || !providerKey) return; + try { + const provider = COMMON_PROVIDERS.find((p) => p.id === selectedProvider); + const defaultUrls = { + openai: "https://api.openai.com", + anthropic: "https://api.anthropic.com", + google: "https://generativelanguage.googleapis.com", + openrouter: "https://openrouter.ai/api", + groq: "https://api.groq.com/openai", + mistral: "https://api.mistral.ai", + }; + await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: selectedProvider, + name: providerName || provider?.name || selectedProvider, + url: providerUrl || defaultUrls[selectedProvider] || "", + apiKey: providerKey, + isActive: true, + }), + }); + handleNext(); + } catch { + handleNext(); + } + }; + + const handleTestProvider = async () => { + setTestStatus("testing"); + setTestMessage("Testing connection..."); + try { + const res = await fetch("/api/providers"); + if (!res.ok) throw new Error("Failed to fetch"); + const data = await res.json(); + const conn = data.connections?.[0]; + if (!conn) { + setTestStatus("error"); + setTestMessage("No provider found. You can add one from the dashboard later."); + return; + } + const testRes = await fetch(`/api/providers/${conn.id}/test`, { method: "POST" }); + if (testRes.ok) { + setTestStatus("success"); + setTestMessage("Connection successful! Your provider is ready."); + } else { + const err = await testRes.json().catch(() => ({})); + setTestStatus("error"); + setTestMessage(err.error || "Test failed, but you can configure this later."); + } + } catch { + setTestStatus("error"); + setTestMessage("Could not test right now. You can test from the dashboard."); + } + }; + + const handleFinish = async () => { + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ setupComplete: true }), + }); + } catch { + // Non-critical + } + router.push("/dashboard"); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+ {/* Progress Indicator */} +
+ {STEPS.map((s, i) => ( +
+
+ {i < step ? ( + check + ) : ( + i + 1 + )} +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Card */} +
+ {/* Step Header */} +
+ + {currentStep.icon} + +

{currentStep.title}

+
+ + {/* Step Content */} +
+ {/* Welcome */} + {currentStep.id === "welcome" && ( +
+

+ OmniRoute is your local AI API proxy. + It routes requests to multiple AI providers with load balancing, failover, and + usage tracking. +

+
+ {[ + { icon: "swap_horiz", label: "Multi-Provider" }, + { icon: "monitoring", label: "Usage Tracking" }, + { icon: "shield", label: "API Key Mgmt" }, + ].map((f) => ( +
+ + {f.icon} + + {f.label} +
+ ))} +
+
+ )} + + {/* Security */} + {currentStep.id === "security" && ( +
+

+ Set a password to protect your dashboard, or skip for now. +

+ + {!skipSecurity && ( +
+ setPassword(e.target.value)} + className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + setConfirmPassword(e.target.value)} + className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {password && confirmPassword && password !== confirmPassword && ( +

Passwords do not match

+ )} +
+ )} +
+ )} + + {/* Provider */} + {currentStep.id === "provider" && ( +
+

+ Connect your first AI provider. You can add more later. +

+
+ {COMMON_PROVIDERS.map((p) => ( + + ))} +
+ {selectedProvider && ( +
+ setProviderKey(e.target.value)} + className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + setProviderUrl(e.target.value)} + className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +
+ )} +
+ )} + + {/* Test */} + {currentStep.id === "test" && ( +
+

+ Let's verify your provider connection works. +

+ {testStatus === "idle" && ( + + )} + {testStatus === "testing" && ( +
+ + progress_activity + + {testMessage} +
+ )} + {testStatus === "success" && ( +
+ check_circle + {testMessage} +
+ )} + {testStatus === "error" && ( +
+
+ warning + {testMessage} +
+ +
+ )} +
+ )} + + {/* Done */} + {currentStep.id === "done" && ( +
+

+ You're all set! Your OmniRoute instance is configured and ready to proxy AI + requests. +

+
+

Your endpoint:

+ http://localhost:20128/api/v1 +
+
+ )} +
+ + {/* Footer Actions */} +
+
+ {step > 0 && !isLastStep && ( + + )} +
+
+ {!isLastStep && step > 0 && ( + + )} + {currentStep.id === "welcome" && ( + + )} + {currentStep.id === "security" && ( + + )} + {currentStep.id === "provider" && ( + + )} + {currentStep.id === "test" && ( + + )} + {isLastStep && ( + + )} +
+
+
+ + {/* Skip Wizard */} + {!isLastStep && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.js b/src/app/(dashboard)/dashboard/page.js new file mode 100644 index 0000000000..9818aa88fc --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.js @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getMachineId } from "@/shared/utils/machine"; +import { getSettings } from "@/lib/localDb"; +import EndpointPageClient from "./endpoint/EndpointPageClient"; + +// Must be dynamic — depends on DB state (setupComplete) that changes at runtime +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const settings = await getSettings(); + if (!settings.setupComplete) { + redirect("/dashboard/onboarding"); + } + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js new file mode 100644 index 0000000000..ea8b62cdf5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ProfilePage() { + redirect("/dashboard/settings"); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js new file mode 100644 index 0000000000..49d6c04226 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -0,0 +1,2191 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import PropTypes from "prop-types"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import { + Card, + Button, + Badge, + Input, + Modal, + CardSkeleton, + OAuthModal, + KiroOAuthWrapper, + CursorAuthModal, + Toggle, + Select, + ProxyConfigModal, +} from "@/shared/components"; +import { + FREE_PROVIDERS, + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + getProviderAlias, + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, +} from "@/shared/constants/providers"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +export default function ProviderDetailPage() { + const params = useParams(); + const router = useRouter(); + const providerId = params.id; + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [providerNode, setProviderNode] = useState(null); + const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [retestingId, setRetestingId] = useState(null); + const [modelAliases, setModelAliases] = useState({}); + const [headerImgError, setHeaderImgError] = useState(false); + const { copied, copy } = useCopyToClipboard(); + const hasAutoOpened = useRef(false); + const userDismissed = useRef(false); + const [proxyTarget, setProxyTarget] = useState(null); + const [proxyConfig, setProxyConfig] = useState(null); + + const providerInfo = providerNode + ? { + id: providerNode.id, + name: + providerNode.name || + (providerNode.type === "anthropic-compatible" + ? "Anthropic Compatible" + : "OpenAI Compatible"), + color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", + textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", + apiType: providerNode.apiType, + baseUrl: providerNode.baseUrl, + type: providerNode.type, + } + : FREE_PROVIDERS[providerId] || OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]; + const isOAuth = !!FREE_PROVIDERS[providerId] || !!OAUTH_PROVIDERS[providerId]; + const models = getModelsByProviderId(providerId); + const providerAlias = getProviderAlias(providerId); + + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); + const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); + const isCompatible = isOpenAICompatible || isAnthropicCompatible; + + const providerStorageAlias = isCompatible ? providerId : providerAlias; + const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; + + // Define callbacks BEFORE the useEffect that uses them + const fetchAliases = useCallback(async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) { + setModelAliases(data.aliases || {}); + } + } catch (error) { + console.log("Error fetching aliases:", error); + } + }, []); + + const fetchConnections = useCallback(async () => { + try { + const [connectionsRes, nodesRes] = await Promise.all([ + fetch("/api/providers", { cache: "no-store" }), + fetch("/api/provider-nodes", { cache: "no-store" }), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) { + const filtered = (connectionsData.connections || []).filter( + (c) => c.provider === providerId + ); + setConnections(filtered); + } + if (nodesRes.ok) { + let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; + + // Newly created compatible nodes can be briefly unavailable on one worker. + // Retry a few times before showing "Provider not found". + if (!node && isCompatible) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" }); + if (!retryRes.ok) continue; + const retryData = await retryRes.json(); + node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null; + if (node) break; + } + } + + setProviderNode(node); + } + } catch (error) { + console.log("Error fetching connections:", error); + } finally { + setLoading(false); + } + }, [providerId, isCompatible]); + + const handleUpdateNode = async (formData) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + + useEffect(() => { + fetchConnections(); + fetchAliases(); + // Load proxy config for visual indicators + fetch("/api/settings/proxy") + .then((r) => (r.ok ? r.json() : null)) + .then((c) => setProxyConfig(c)) + .catch(() => {}); + }, [fetchConnections, fetchAliases]); + + // Auto-open Add Connection modal when no connections exist (better UX) + // Only fires once on initial load, not on HMR remounts or after user dismissal + useEffect(() => { + if ( + !loading && + connections.length === 0 && + providerInfo && + !isCompatible && + !hasAutoOpened.current && + !userDismissed.current + ) { + hasAutoOpened.current = true; + if (isOAuth) { + setShowOAuthModal(true); + } else { + setShowAddApiKeyModal(true); + } + } + }, [loading]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { + const fullModel = `${providerAliasOverride}/${modelId}`; + try { + const res = await fetch("/api/models/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: fullModel, alias }), + }); + if (res.ok) { + await fetchAliases(); + } else { + const data = await res.json(); + alert(data.error || "Failed to set alias"); + } + } catch (error) { + console.log("Error setting alias:", error); + } + }; + + const handleDeleteAlias = async (alias) => { + try { + const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { + method: "DELETE", + }); + if (res.ok) { + await fetchAliases(); + } + } catch (error) { + console.log("Error deleting alias:", error); + } + }; + + const handleDelete = async (id) => { + if (!confirm("Delete this connection?")) return; + try { + const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); + if (res.ok) { + setConnections(connections.filter((c) => c.id !== id)); + } + } catch (error) { + console.log("Error deleting connection:", error); + } + }; + + const handleOAuthSuccess = useCallback(() => { + fetchConnections(); + setShowOAuthModal(false); + }, [fetchConnections]); + + const handleSaveApiKey = async (formData) => { + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...formData }), + }); + if (res.ok) { + await fetchConnections(); + setShowAddApiKeyModal(false); + } + } catch (error) { + console.log("Error saving connection:", error); + } + }; + + const handleUpdateConnection = async (formData) => { + try { + const res = await fetch(`/api/providers/${selectedConnection.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + } + } catch (error) { + console.log("Error updating connection:", error); + } + }; + + const handleUpdateConnectionStatus = async (id, isActive) => { + try { + const res = await fetch(`/api/providers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) { + setConnections((prev) => prev.map((c) => (c.id === id ? { ...c, isActive } : c))); + } + } catch (error) { + console.log("Error updating connection status:", error); + } + }; + + const handleToggleRateLimit = async (connectionId, enabled) => { + try { + const res = await fetch("/api/rate-limit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionId, enabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling rate limit:", error); + } + }; + + const handleRetestConnection = async (connectionId) => { + if (!connectionId || retestingId) return; + setRetestingId(connectionId); + try { + const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "Failed to retest connection"); + return; + } + await fetchConnections(); + } catch (error) { + console.error("Error retesting connection:", error); + } finally { + setRetestingId(null); + } + }; + + const handleSwapPriority = async (conn1, conn2) => { + if (!conn1 || !conn2) return; + try { + // If they have the same priority, we need to ensure the one moving up + // gets a lower value than the one moving down. + // We use a small offset which the backend re-indexing will fix. + let p1 = conn2.priority; + let p2 = conn1.priority; + + if (p1 === p2) { + // If moving conn1 "up" (index decreases) + const isConn1MovingUp = connections.indexOf(conn1) > connections.indexOf(conn2); + if (isConn1MovingUp) { + p1 = conn2.priority - 0.5; + } else { + p1 = conn2.priority + 0.5; + } + } + + await Promise.all([ + fetch(`/api/providers/${conn1.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: p1 }), + }), + fetch(`/api/providers/${conn2.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: p2 }), + }), + ]); + await fetchConnections(); + } catch (error) { + console.log("Error swapping priority:", error); + } + }; + + const renderModelsSection = () => { + if (isCompatible) { + return ( + + ); + } + if (providerInfo.passthroughModels) { + return ( + + ); + } + if (models.length === 0) { + return

No models configured

; + } + return ( +
+ {models.map((model) => { + const fullModel = `${providerStorageAlias}/${model.id}`; + const oldFormatModel = `${providerId}/${model.id}`; + const existingAlias = Object.entries(modelAliases).find( + ([, m]) => m === fullModel || m === oldFormatModel + )?.[0]; + return ( + handleSetAlias(model.id, alias, providerStorageAlias)} + onDeleteAlias={() => handleDeleteAlias(existingAlias)} + /> + ); + })} +
+ ); + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!providerInfo) { + return ( +
+

Provider not found

+ + Back to Providers + +
+ ); + } + + // Determine icon path: OpenAI Compatible providers use specialized icons + const getHeaderIconPath = () => { + if (isOpenAICompatible && providerInfo.apiType) { + return providerInfo.apiType === "responses" + ? "/providers/oai-r.png" + : "/providers/oai-cc.png"; + } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; + } + return `/providers/${providerInfo.id}.png`; + }; + + return ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +
+
+ {headerImgError ? ( + + {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} + + ) : ( + {providerInfo.name} setHeaderImgError(true)} + /> + )} +
+
+ {providerInfo.website ? ( + + {providerInfo.name} + open_in_new + + ) : ( +

{providerInfo.name}

+ )} +

+ {connections.length} connection{connections.length === 1 ? "" : "s"} +

+
+
+
+ + {isCompatible && providerNode && ( + +
+
+

+ {isAnthropicCompatible + ? "Anthropic Compatible Details" + : "OpenAI Compatible Details"} +

+

+ {isAnthropicCompatible + ? "Messages API" + : providerNode.apiType === "responses" + ? "Responses API" + : "Chat Completions"}{" "} + · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {isAnthropicCompatible + ? "messages" + : providerNode.apiType === "responses" + ? "responses" + : "chat/completions"} +

+
+
+ + + +
+
+ {connections.length > 0 && ( +

+ Only one connection is allowed per compatible node. Add another node if you need more + connections. +

+ )} +
+ )} + + {/* Connections */} + +
+

Connections

+ {!isCompatible && ( + + )} +
+ + {connections.length === 0 ? ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

No connections yet

+

Add your first connection to get started

+ {!isCompatible && ( + + )} +
+ ) : ( +
+ {connections + .sort((a, b) => (a.priority || 0) - (b.priority || 0)) + .map((conn, index) => ( + handleSwapPriority(conn, connections[index - 1])} + onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} + onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: conn.name || conn.email || conn.id, + }) + } + hasProxy={!!proxyConfig?.keys?.[conn.id]} + /> + ))} +
+ )} +
+ + {/* Models */} + +

+ {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} +

+ {renderModelsSection()} + + {/* Custom Models — available for ALL providers */} + {!isCompatible && ( + + )} +
+ + {/* Modals */} + {providerId === "kiro" ? ( + { + userDismissed.current = true; + setShowOAuthModal(false); + }} + /> + ) : providerId === "cursor" ? ( + { + userDismissed.current = true; + setShowOAuthModal(false); + }} + /> + ) : ( + { + userDismissed.current = true; + setShowOAuthModal(false); + }} + /> + )} + setShowAddApiKeyModal(false)} + /> + setShowEditModal(false)} + /> + {isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicCompatible} + /> + )} + {/* Proxy Config Modal */} + {proxyTarget && ( + setProxyTarget(null)} + level={proxyTarget.level} + levelId={proxyTarget.id} + levelLabel={proxyTarget.label} + /> + )} +
+ ); +} + +function ModelRow({ model, fullModel, alias, copied, onCopy }) { + return ( +
+ smart_toy + + {fullModel} + + +
+ ); +} + +ModelRow.propTypes = { + model: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + fullModel: PropTypes.string.isRequired, + alias: PropTypes.string, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, +}; + +function PassthroughModelsSection({ + providerAlias, + modelAliases, + copied, + onCopy, + onSetAlias, + onDeleteAlias, +}) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + + // Filter aliases for this provider - models are persisted via alias + const providerAliases = Object.entries(modelAliases).filter(([, model]) => + model.startsWith(`${providerAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerAlias}/`, ""), + fullModel, + alias, + })); + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const defaultAlias = generateDefaultAlias(modelId); + + // Check if alias already exists + if (modelAliases[defaultAlias]) { + alert( + `Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.` + ); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, defaultAlias); + setNewModel(""); + } catch (error) { + console.log("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + return ( +
+

+ OpenRouter supports any model. Add models and create aliases for quick access. +

+ + {/* Add new model */} +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="anthropic/claude-3-opus" + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* Models list */} + {allModels.length > 0 && ( +
+ {allModels.map(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + /> + ))} +
+ )} +
+ ); +} + +PassthroughModelsSection.propTypes = { + providerAlias: PropTypes.string.isRequired, + modelAliases: PropTypes.object.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onSetAlias: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, +}; + +function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) { + return ( +
+ smart_toy + +
+

{modelId}

+ +
+ + {fullModel} + + +
+
+ + {/* Delete button */} + +
+ ); +} + +PassthroughModelRow.propTypes = { + modelId: PropTypes.string.isRequired, + fullModel: PropTypes.string.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, +}; + +// ============ Custom Models Section (for ALL providers) ============ + +function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { + const [customModels, setCustomModels] = useState([]); + const [newModelId, setNewModelId] = useState(""); + const [newModelName, setNewModelName] = useState(""); + const [adding, setAdding] = useState(false); + const [loading, setLoading] = useState(true); + + const fetchCustomModels = useCallback(async () => { + try { + const res = await fetch(`/api/provider-models?provider=${providerId}`); + if (res.ok) { + const data = await res.json(); + setCustomModels(data.models || []); + } + } catch (e) { + console.error("Failed to fetch custom models:", e); + } finally { + setLoading(false); + } + }, [providerId]); + + useEffect(() => { + fetchCustomModels(); + }, [fetchCustomModels]); + + const handleAdd = async () => { + if (!newModelId.trim() || adding) return; + setAdding(true); + try { + const res = await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId: newModelId.trim(), + modelName: newModelName.trim() || undefined, + }), + }); + if (res.ok) { + setNewModelId(""); + setNewModelName(""); + await fetchCustomModels(); + } + } catch (e) { + console.error("Failed to add custom model:", e); + } finally { + setAdding(false); + } + }; + + const handleRemove = async (modelId) => { + try { + await fetch( + `/api/provider-models?provider=${providerId}&model=${encodeURIComponent(modelId)}`, + { + method: "DELETE", + } + ); + await fetchCustomModels(); + } catch (e) { + console.error("Failed to remove custom model:", e); + } + }; + + return ( +
+

+ tune + Custom Models +

+

+ Add model IDs not in the default list. These will be available for routing. +

+ + {/* Add form */} +
+
+ + setNewModelId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="e.g. gpt-4.5-turbo" + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+
+ + setNewModelName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="Optional" + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* List */} + {loading ? ( +

Loading...

+ ) : customModels.length > 0 ? ( +
+ {customModels.map((model) => { + const fullModel = `${providerAlias}/${model.id}`; + const copyKey = `custom-${model.id}`; + return ( +
+ tune +
+

{model.name || model.id}

+
+ + {fullModel} + + +
+
+ +
+ ); + })} +
+ ) : ( +

No custom models added yet.

+ )} +
+ ); +} + +CustomModelsSection.propTypes = { + providerId: PropTypes.string.isRequired, + providerAlias: PropTypes.string.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, +}; + +function CompatibleModelsSection({ + providerStorageAlias, + providerDisplayAlias, + modelAliases, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + connections, + isAnthropic, +}) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + const [importing, setImporting] = useState(false); + + const providerAliases = Object.entries(modelAliases).filter(([, model]) => + model.startsWith(`${providerStorageAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerStorageAlias}/`, ""), + fullModel, + alias, + })); + + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const resolveAlias = (modelId) => { + const baseAlias = generateDefaultAlias(modelId); + if (!modelAliases[baseAlias]) return baseAlias; + const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; + if (!modelAliases[prefixedAlias]) return prefixedAlias; + return null; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) { + alert( + "All suggested aliases already exist. Please choose a different model or remove conflicting aliases." + ); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + setNewModel(""); + } catch (error) { + console.log("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + const handleImport = async () => { + if (importing) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) return; + + setImporting(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || "Failed to import models"); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert("No models returned from /models."); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) continue; + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + importedCount += 1; + } + if (importedCount === 0) { + alert("No new models were added."); + } + } catch (error) { + console.log("Error importing models:", error); + } finally { + setImporting(false); + } + }; + + const canImport = connections.some((conn) => conn.isActive !== false); + + return ( +
+

+ Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from + the /models endpoint. +

+ +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + +
+ + {!canImport && ( +

Add a connection to enable importing models.

+ )} + + {allModels.length > 0 && ( +
+ {allModels.map(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + /> + ))} +
+ )} +
+ ); +} + +CompatibleModelsSection.propTypes = { + providerStorageAlias: PropTypes.string.isRequired, + providerDisplayAlias: PropTypes.string.isRequired, + modelAliases: PropTypes.object.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onSetAlias: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, + connections: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + isActive: PropTypes.bool, + }) + ).isRequired, + isAnthropic: PropTypes.bool, +}; + +function CooldownTimer({ until }) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + const updateRemaining = () => { + const diff = new Date(until).getTime() - Date.now(); + if (diff <= 0) { + setRemaining(""); + return; + } + const secs = Math.floor(diff / 1000); + if (secs < 60) { + setRemaining(`${secs}s`); + } else if (secs < 3600) { + setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); + } else { + const hrs = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + setRemaining(`${hrs}h ${mins}m`); + } + }; + + updateRemaining(); + const interval = setInterval(updateRemaining, 1000); + return () => clearInterval(interval); + }, [until]); + + if (!remaining) return null; + + return ⏱ {remaining}; +} + +CooldownTimer.propTypes = { + until: PropTypes.string.isRequired, +}; + +const ERROR_TYPE_LABELS = { + runtime_error: { label: "Local runtime", variant: "warning" }, + upstream_auth_error: { label: "Upstream auth", variant: "error" }, + auth_missing: { label: "Missing credential", variant: "warning" }, + token_refresh_failed: { label: "Refresh failed", variant: "warning" }, + token_expired: { label: "Token expired", variant: "warning" }, + upstream_rate_limited: { label: "Rate limited", variant: "warning" }, + upstream_unavailable: { label: "Upstream unavailable", variant: "error" }, + network_error: { label: "Network error", variant: "warning" }, + unsupported: { label: "Test unsupported", variant: "default" }, + upstream_error: { label: "Upstream error", variant: "error" }, +}; + +function inferErrorType(connection, isCooldown) { + if (isCooldown) return "upstream_rate_limited"; + if (connection.lastErrorType) return connection.lastErrorType; + + const code = Number(connection.errorCode); + if (code === 401 || code === 403) return "upstream_auth_error"; + if (code === 429) return "upstream_rate_limited"; + if (code >= 500) return "upstream_unavailable"; + + const msg = (connection.lastError || "").toLowerCase(); + if (!msg) return null; + if ( + msg.includes("runtime") || + msg.includes("not runnable") || + msg.includes("not installed") || + msg.includes("healthcheck") + ) + return "runtime_error"; + if (msg.includes("refresh failed")) return "token_refresh_failed"; + if (msg.includes("token expired") || msg.includes("expired")) return "token_expired"; + if ( + msg.includes("invalid api key") || + msg.includes("token invalid") || + msg.includes("revoked") || + msg.includes("access denied") || + msg.includes("unauthorized") + ) + return "upstream_auth_error"; + if ( + msg.includes("rate limit") || + msg.includes("quota") || + msg.includes("too many requests") || + msg.includes("429") + ) + return "upstream_rate_limited"; + if ( + msg.includes("fetch failed") || + msg.includes("network") || + msg.includes("timeout") || + msg.includes("econn") || + msg.includes("enotfound") + ) + return "network_error"; + if (msg.includes("not supported")) return "unsupported"; + return "upstream_error"; +} + +function getStatusPresentation(connection, effectiveStatus, isCooldown) { + if (connection.isActive === false) { + return { + statusVariant: "default", + statusLabel: "disabled", + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + if (effectiveStatus === "active" || effectiveStatus === "success") { + return { + statusVariant: "success", + statusLabel: "connected", + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + const errorType = inferErrorType(connection, isCooldown); + const errorBadge = errorType ? ERROR_TYPE_LABELS[errorType] || null : null; + + if (errorType === "runtime_error") { + return { + statusVariant: "warning", + statusLabel: "runtime issue", + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if ( + errorType === "upstream_auth_error" || + errorType === "auth_missing" || + errorType === "token_refresh_failed" || + errorType === "token_expired" + ) { + return { + statusVariant: "error", + statusLabel: "auth failed", + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; + } + + if (errorType === "upstream_rate_limited") { + return { + statusVariant: "warning", + statusLabel: "rate limited", + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "network_error") { + return { + statusVariant: "warning", + statusLabel: "network issue", + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "unsupported") { + return { + statusVariant: "default", + statusLabel: "test unsupported", + errorType, + errorBadge, + errorTextClass: "text-text-muted", + }; + } + + return { + statusVariant: "error", + statusLabel: effectiveStatus || "error", + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; +} + +function ConnectionRow({ + connection, + isOAuth, + isFirst, + isLast, + onMoveUp, + onMoveDown, + onToggleActive, + onToggleRateLimit, + onRetest, + isRetesting, + onEdit, + onDelete, + onReauth, + onProxy, + hasProxy, +}) { + const displayName = isOAuth + ? connection.name || connection.email || connection.displayName || "OAuth Account" + : connection.name; + + // Use useState + useEffect for impure Date.now() to avoid calling during render + const [isCooldown, setIsCooldown] = useState(false); + + useEffect(() => { + const checkCooldown = () => { + const cooldown = + connection.rateLimitedUntil && new Date(connection.rateLimitedUntil).getTime() > Date.now(); + setIsCooldown(cooldown); + }; + + checkCooldown(); + // Update every second while in cooldown + const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null; + return () => { + if (interval) clearInterval(interval); + }; + }, [connection.rateLimitedUntil]); + + // Determine effective status (override unavailable if cooldown expired) + const effectiveStatus = + connection.testStatus === "unavailable" && !isCooldown + ? "active" // Cooldown expired → treat as active + : connection.testStatus; + + const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown); + const rateLimitEnabled = !!connection.rateLimitProtection; + + return ( +
+
+ {/* Priority arrows */} +
+ + +
+ + {isOAuth ? "lock" : "key"} + +
+

{displayName}

+
+ + {statusPresentation.statusLabel} + + {isCooldown && connection.isActive !== false && ( + + )} + {statusPresentation.errorBadge && connection.isActive !== false && ( + + {statusPresentation.errorBadge.label} + + )} + {connection.lastError && connection.isActive !== false && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + Auto: {connection.globalPriority} + )} + {/* Rate Limit Protection — inline toggle with label */} + | + + {hasProxy && ( + <> + | + + vpn_lock + Proxy + + + )} +
+
+
+
+ + +
+ {onReauth && ( + + )} + + + +
+
+
+ ); +} + +ConnectionRow.propTypes = { + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + displayName: PropTypes.string, + rateLimitedUntil: PropTypes.string, + rateLimitProtection: PropTypes.bool, + testStatus: PropTypes.string, + isActive: PropTypes.bool, + priority: PropTypes.number, + lastError: PropTypes.string, + lastErrorType: PropTypes.string, + lastErrorSource: PropTypes.string, + errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + globalPriority: PropTypes.number, + }).isRequired, + isOAuth: PropTypes.bool.isRequired, + isFirst: PropTypes.bool.isRequired, + isLast: PropTypes.bool.isRequired, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onToggleActive: PropTypes.func.isRequired, + onToggleRateLimit: PropTypes.func.isRequired, + onRetest: PropTypes.func.isRequired, + isRetesting: PropTypes.bool, + onEdit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onReauth: PropTypes.func, +}; + +function AddApiKeyModal({ + isOpen, + provider, + providerName, + isCompatible, + isAnthropic, + onSave, + onClose, +}) { + const [formData, setFormData] = useState({ + name: "", + apiKey: "", + priority: 1, + }); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const handleSubmit = async () => { + if (!provider || !formData.apiKey) return; + + setSaving(true); + try { + let isValid = false; + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + + await onSave({ + name: formData.name, + apiKey: formData.apiKey, + priority: formData.priority, + testStatus: isValid ? "active" : "unknown", + }); + } finally { + setSaving(false); + } + }; + + if (!provider) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Production Key" + /> +
+ setFormData({ ...formData, apiKey: e.target.value })} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + {isCompatible && ( +

+ {isAnthropic + ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` + : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.`} +

+ )} + + setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 }) + } + /> +
+ + +
+
+
+ ); +} + +AddApiKeyModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + provider: PropTypes.string, + providerName: PropTypes.string, + isCompatible: PropTypes.bool, + isAnthropic: PropTypes.bool, + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +function EditConnectionModal({ isOpen, connection, onSave, onClose }) { + const [formData, setFormData] = useState({ + name: "", + priority: 1, + apiKey: "", + healthCheckInterval: 60, + }); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (connection) { + setFormData({ + name: connection.name || "", + priority: connection.priority || 1, + apiKey: "", + healthCheckInterval: connection.healthCheckInterval ?? 60, + }); + setTestResult(null); + setValidationResult(null); + } + }, [connection]); + + const handleTest = async () => { + if (!connection?.provider) return; + setTesting(true); + setTestResult(null); + try { + const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); + const data = await res.json(); + setTestResult({ + valid: !!data.valid, + diagnosis: data.diagnosis || null, + message: data.error || null, + }); + } catch { + setTestResult({ + valid: false, + diagnosis: { type: "network_error" }, + message: "Failed to test connection", + }); + } finally { + setTesting(false); + } + }; + + const handleValidate = async () => { + if (!connection?.provider || !formData.apiKey) return; + setValidating(true); + setValidationResult(null); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const handleSubmit = async () => { + setSaving(true); + try { + const updates = { + name: formData.name, + priority: formData.priority, + healthCheckInterval: formData.healthCheckInterval, + }; + if (!isOAuth && formData.apiKey) { + updates.apiKey = formData.apiKey; + let isValid = validationResult === "success"; + if (!isValid) { + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + } + if (isValid) { + updates.testStatus = "active"; + updates.lastError = null; + updates.lastErrorAt = null; + updates.lastErrorType = null; + updates.lastErrorSource = null; + updates.errorCode = null; + updates.rateLimitedUntil = null; + } + } + await onSave(updates); + } finally { + setSaving(false); + } + }; + + if (!connection) return null; + + const isOAuth = connection.authType === "oauth"; + const isCompatible = + isOpenAICompatibleProvider(connection.provider) || + isAnthropicCompatibleProvider(connection.provider); + const testErrorMeta = + !testResult?.valid && testResult?.diagnosis?.type + ? ERROR_TYPE_LABELS[testResult.diagnosis.type] || null + : null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={isOAuth ? "Account name" : "Production Key"} + /> + {isOAuth && connection.email && ( +
+

Email

+

{connection.email}

+
+ )} + {isOAuth && ( + + setFormData({ + ...formData, + healthCheckInterval: Math.max(0, Number.parseInt(e.target.value) || 0), + }) + } + hint="Proactive token refresh interval. 0 = disabled." + /> + )} + + setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 }) + } + /> + {!isOAuth && ( + <> +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter new API key" + hint="Leave blank to keep the current API key." + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + + )} + + {/* Test Connection */} + {!isCompatible && ( +
+ + {testResult && ( + <> + + {testResult.valid ? "Valid" : "Failed"} + + {testErrorMeta && ( + {testErrorMeta.label} + )} + + )} +
+ )} + +
+ + +
+
+
+ ); +} + +EditConnectionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + priority: PropTypes.number, + authType: PropTypes.string, + provider: PropTypes.string, + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + const [saving, setSaving] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + if (node) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + apiType: node.apiType || "chat", + baseUrl: + node.baseUrl || + (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), + }); + } + }, [node, isAnthropic]); + + const apiTypeOptions = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, + ]; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSaving(true); + try { + const payload = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + }; + if (!isAnthropic) { + payload.apiType = formData.apiType; + } + await onSave(payload); + } finally { + setSaving(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: isAnthropic ? "anthropic-compatible" : "openai-compatible", + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + if (!node) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={isAnthropic ? "ac-prod" : "oc-prod"} + hint="Required. Used as the provider prefix for model IDs." + /> + {!isAnthropic && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} + hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +EditCompatibleNodeModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + node: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + prefix: PropTypes.string, + apiType: PropTypes.string, + baseUrl: PropTypes.string, + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + isAnthropic: PropTypes.bool, +}; diff --git a/src/app/(dashboard)/dashboard/providers/new/page.js b/src/app/(dashboard)/dashboard/providers/new/page.js new file mode 100644 index 0000000000..ba94d672c9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/new/page.js @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Card, Button, Input, Select, Toggle } from "@/shared/components"; +import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config"; + +const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({ + value: p.id, + label: p.name, +})); + +const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({ + value: m.id, + label: m.name, +})); + +export default function NewProviderPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + provider: "", + authMethod: "api_key", + apiKey: "", + displayName: "", + isActive: true, + }); + const [errors, setErrors] = useState({}); + + const handleChange = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: null })); + } + }; + + const validate = () => { + const newErrors = {}; + if (!formData.provider) newErrors.provider = "Please select a provider"; + if (formData.authMethod === "api_key" && !formData.apiKey) { + newErrors.apiKey = "API Key is required"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!validate()) return; + + setLoading(true); + try { + const response = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + if (response.ok) { + router.push("/dashboard/providers"); + } else { + const data = await response.json(); + setErrors({ submit: data.error || "Failed to create provider" }); + } + } catch (error) { + setErrors({ submit: "An error occurred. Please try again." }); + } finally { + setLoading(false); + } + }; + + const selectedProvider = AI_PROVIDERS[formData.provider]; + + return ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +

Add New Provider

+

+ Configure a new AI provider to use with your applications. +

+
+ + {/* Form */} + +
+ {/* Provider Selection */} + handleChange("apiKey", e.target.value)} + error={errors.apiKey} + hint="Your API key will be encrypted and stored securely." + required + /> + )} + + {/* OAuth2 Button */} + {formData.authMethod === "oauth2" && ( + +

+ Connect your account using OAuth2 authentication. +

+ +
+ )} + + {/* Display Name */} + handleChange("displayName", e.target.value)} + hint="Optional. A friendly name to identify this configuration." + /> + + {/* Active Toggle */} + handleChange("isActive", checked)} + label="Active" + description="Enable this provider for use in your applications" + /> + + {/* Error Message */} + {errors.submit && ( +
+ {errors.submit} +
+ )} + + {/* Actions */} +
+ + + + +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js new file mode 100644 index 0000000000..174cd13e21 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -0,0 +1,949 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import PropTypes from "prop-types"; +import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { + FREE_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; +import Link from "next/link"; +import { getErrorCode, getRelativeTime } from "@/shared/utils"; + +// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard +function getStatusDisplay(connected, error, errorCode) { + const parts = []; + if (connected > 0) { + parts.push( + + {connected} Connected + + ); + } + if (error > 0) { + const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; + parts.push( + + {errText} + + ); + } + if (parts.length === 0) { + return No connections; + } + return parts; +} + +function getConnectionErrorTag(connection) { + if (!connection) return null; + + const explicitType = connection.lastErrorType; + if (explicitType === "runtime_error") return "RUNTIME"; + if ( + explicitType === "upstream_auth_error" || + explicitType === "auth_missing" || + explicitType === "token_refresh_failed" || + explicitType === "token_expired" + ) { + return "AUTH"; + } + if (explicitType === "upstream_rate_limited") return "429"; + if (explicitType === "upstream_unavailable") return "5XX"; + if (explicitType === "network_error") return "NET"; + + const numericCode = Number(connection.errorCode); + if (Number.isFinite(numericCode) && numericCode >= 400) { + return String(numericCode); + } + + const fromMessage = getErrorCode(connection.lastError); + if (fromMessage === "401" || fromMessage === "403") return "AUTH"; + if (fromMessage && fromMessage !== "ERR") return fromMessage; + + const msg = (connection.lastError || "").toLowerCase(); + if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) + return "RUNTIME"; + if ( + msg.includes("invalid api key") || + msg.includes("token invalid") || + msg.includes("revoked") || + msg.includes("unauthorized") + ) + return "AUTH"; + + return "ERR"; +} + +export default function ProvidersPage() { + const [connections, setConnections] = useState([]); + const [providerNodes, setProviderNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); + const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); + const [testingMode, setTestingMode] = useState(null); + const [testResults, setTestResults] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + const [connectionsRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) setConnections(connectionsData.connections || []); + if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); + } catch (error) { + console.log("Error fetching data:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const getProviderStats = (providerId, authType) => { + const providerConnections = connections.filter( + (c) => c.provider === providerId && c.authType === authType + ); + + // Helper: check if connection is effectively active (cooldown expired) + const getEffectiveStatus = (conn) => { + const isCooldown = + conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now(); + return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; + }; + + const connected = providerConnections.filter((c) => { + const status = getEffectiveStatus(c); + return status === "active" || status === "success"; + }).length; + + const errorConns = providerConnections.filter((c) => { + const status = getEffectiveStatus(c); + return status === "error" || status === "expired" || status === "unavailable"; + }); + + const error = errorConns.length; + const total = providerConnections.length; + + // Get latest error info + const latestError = errorConns.sort( + (a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0) + )[0]; + const errorCode = latestError ? getConnectionErrorTag(latestError) : null; + const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; + + return { connected, error, total, errorCode, errorTime }; + }; + + const handleBatchTest = async (mode, providerId = null) => { + if (testingMode) return; + setTestingMode(mode === "provider" ? providerId : mode); + setTestResults(null); + try { + const res = await fetch("/api/providers/test-batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, providerId }), + }); + const data = await res.json(); + setTestResults(data); + } catch (error) { + setTestResults({ error: "Test request failed" }); + } finally { + setTestingMode(null); + } + }; + + const compatibleProviders = providerNodes + .filter((node) => node.type === "openai-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "OpenAI Compatible", + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + })); + + const anthropicCompatibleProviders = providerNodes + .filter((node) => node.type === "anthropic-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "Anthropic Compatible", + color: "#D97757", + textIcon: "AC", + })); + + if (loading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* OAuth Providers */} +
+
+

OAuth Providers

+ +
+
+ {Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( + + ))} +
+
+ + {/* Free Providers */} +
+
+

Free Providers

+ +
+
+ {Object.entries(FREE_PROVIDERS).map(([key, info]) => ( + + ))} +
+
+ + {/* API Key Providers — fixed list */} +
+
+

API Key Providers

+ +
+
+ {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( + + ))} +
+
+ + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} +
+
+

API Key Compatible Providers

+
+ {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && ( + + )} + + +
+
+ {compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? ( +
+ + extension + +

No compatible providers added yet

+

+ Use the buttons above to add OpenAI or Anthropic compatible endpoints +

+
+ ) : ( +
+ {[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => ( + + ))} +
+ )} +
+ setShowAddCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => [...prev, node]); + setShowAddCompatibleModal(false); + }} + /> + setShowAddAnthropicCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => [...prev, node]); + setShowAddAnthropicCompatibleModal(false); + }} + /> + {/* Test Results Modal */} + {testResults && ( +
setTestResults(null)} + > +
+
e.stopPropagation()} + > +
+

Test Results

+ +
+
+ +
+
+
+ )} +
+ ); +} + +function ProviderCard({ providerId, provider, stats }) { + const { connected, error, errorCode, errorTime } = stats; + const [imgError, setImgError] = useState(false); + + return ( + + +
+
+
+ {imgError ? ( + + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} + + ) : ( + {provider.name} setImgError(true)} + /> + )} +
+
+

{provider.name}

+
+ {getStatusDisplay(connected, error, errorCode)} + {errorTime && • {errorTime}} +
+
+
+ + chevron_right + +
+
+ + ); +} + +ProviderCard.propTypes = { + providerId: PropTypes.string.isRequired, + provider: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + textIcon: PropTypes.string, + }).isRequired, + stats: PropTypes.shape({ + connected: PropTypes.number, + error: PropTypes.number, + errorCode: PropTypes.string, + errorTime: PropTypes.string, + }).isRequired, +}; + +// API Key providers - use image with textIcon fallback (same as OAuth providers) +function ApiKeyProviderCard({ providerId, provider, stats }) { + const { connected, error, errorCode, errorTime } = stats; + const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); + const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); + const [imgError, setImgError] = useState(false); + + // Determine icon path: OpenAI Compatible providers use specialized icons + const getIconPath = () => { + if (isCompatible) { + return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; + } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; // Use Anthropic icon as base + } + return `/providers/${provider.id}.png`; + }; + + return ( + + +
+
+
+ {imgError ? ( + + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} + + ) : ( + {provider.name} setImgError(true)} + /> + )} +
+
+

{provider.name}

+
+ {getStatusDisplay(connected, error, errorCode)} + {isCompatible && ( + + {provider.apiType === "responses" ? "Responses" : "Chat"} + + )} + {isAnthropicCompatible && ( + + Messages + + )} + {errorTime && • {errorTime}} +
+
+
+ + chevron_right + +
+
+ + ); +} + +ApiKeyProviderCard.propTypes = { + providerId: PropTypes.string.isRequired, + provider: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + textIcon: PropTypes.string, + apiType: PropTypes.string, + }).isRequired, + stats: PropTypes.shape({ + connected: PropTypes.number, + error: PropTypes.number, + errorCode: PropTypes.string, + errorTime: PropTypes.string, + }).isRequired, +}; + +function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + const apiTypeOptions = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, + ]; + + useEffect(() => { + const defaultBaseUrl = "https://api.openai.com/v1"; + setFormData((prev) => ({ + ...prev, + baseUrl: defaultBaseUrl, + })); + }, [formData.apiType]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + prefix: formData.prefix, + apiType: formData.apiType, + baseUrl: formData.baseUrl, + type: "openai-compatible", + }), + }); + const data = await res.json(); + if (res.ok) { + onCreated(data.node); + setFormData({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + setCheckKey(""); + setValidationResult(null); + } + } catch (error) { + console.log("Error creating OpenAI Compatible node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: "openai-compatible", + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="OpenAI Compatible (Prod)" + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="oc-prod" + hint="Required. Used as the provider prefix for model IDs." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.openai.com/v1" + hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +AddOpenAICompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; + +function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + baseUrl: "https://api.anthropic.com/v1", + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + // Reset validation when modal opens + if (isOpen) { + setValidationResult(null); + setCheckKey(""); + } + }, [isOpen]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + type: "anthropic-compatible", + }), + }); + const data = await res.json(); + if (res.ok) { + onCreated(data.node); + setFormData({ + name: "", + prefix: "", + baseUrl: "https://api.anthropic.com/v1", + }); + setCheckKey(""); + setValidationResult(null); + } + } catch (error) { + console.log("Error creating Anthropic Compatible node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: "anthropic-compatible", + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Anthropic Compatible (Prod)" + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="ac-prod" + hint="Required. Used as the provider prefix for model IDs." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.anthropic.com/v1" + hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages." + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +AddAnthropicCompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; + +// ─── Provider Test Results View (mirrors combo TestResultsView) ────────────── + +function ProviderTestResultsView({ results }) { + if (results.error && !results.results) { + return ( +
+ error +

{results.error}

+
+ ); + } + + const { summary, mode } = results; + const items = results.results || []; + + const modeLabel = + { + oauth: "OAuth", + free: "Free", + apikey: "API Key", + provider: "Provider", + all: "All", + }[mode] || mode; + + return ( +
+ {/* Summary header */} + {summary && ( +
+ {modeLabel} Test + + {summary.passed} passed + + {summary.failed > 0 && ( + + {summary.failed} failed + + )} + {summary.total} tested +
+ )} + + {/* Individual results */} + {items.map((r, i) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ {r.connectionName} + ({r.provider}) +
+ {r.latencyMs !== undefined && ( + {r.latencyMs}ms + )} + + {r.valid ? "OK" : r.diagnosis?.type || "ERROR"} + +
+ ))} + + {items.length === 0 && ( +
+ No active connections found for this group. +
+ )} +
+ ); +} + +ProviderTestResultsView.propTypes = { + results: PropTypes.shape({ + mode: PropTypes.string, + results: PropTypes.array, + summary: PropTypes.shape({ + total: PropTypes.number, + passed: PropTypes.number, + failed: PropTypes.number, + }), + error: PropTypes.string, + }).isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.js b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.js new file mode 100644 index 0000000000..c367c499ad --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.js @@ -0,0 +1,59 @@ +"use client"; + +import { Card, Toggle } from "@/shared/components"; +import { useTheme } from "@/shared/hooks/useTheme"; +import { cn } from "@/shared/utils/cn"; + +export default function AppearanceTab() { + const { theme, setTheme, isDark } = useTheme(); + + return ( + +
+
+ +
+

Appearance

+
+
+
+
+

Dark Mode

+

Switch between light and dark themes

+
+ setTheme(isDark ? "light" : "dark")} /> +
+ +
+
+ {["light", "dark", "system"].map((option) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.js b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.js new file mode 100644 index 0000000000..1f33188b9b --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.js @@ -0,0 +1,287 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input, Toggle } from "@/shared/components"; +import { cn } from "@/shared/utils/cn"; + +export default function ComboDefaultsTab() { + const [comboDefaults, setComboDefaults] = useState({ + strategy: "priority", + maxRetries: 1, + retryDelayMs: 2000, + timeoutMs: 120000, + healthCheckEnabled: true, + healthCheckTimeoutMs: 3000, + maxComboDepth: 3, + trackMetrics: true, + }); + const [providerOverrides, setProviderOverrides] = useState({}); + const [newOverrideProvider, setNewOverrideProvider] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch("/api/settings/combo-defaults") + .then((res) => res.json()) + .then((data) => { + if (data.comboDefaults) setComboDefaults(data.comboDefaults); + if (data.providerOverrides) setProviderOverrides(data.providerOverrides); + }) + .catch((err) => console.error("Failed to fetch combo defaults:", err)); + }, []); + + const saveComboDefaults = async () => { + setSaving(true); + try { + await fetch("/api/settings/combo-defaults", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboDefaults, providerOverrides }), + }); + } catch (err) { + console.error("Failed to save combo defaults:", err); + } finally { + setSaving(false); + } + }; + + const addProviderOverride = () => { + const name = newOverrideProvider.trim().toLowerCase(); + if (!name || providerOverrides[name]) return; + setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1, timeoutMs: 120000 } })); + setNewOverrideProvider(""); + }; + + const removeProviderOverride = (provider) => { + setProviderOverrides((prev) => { + const copy = { ...prev }; + delete copy[provider]; + return copy; + }); + }; + + return ( + +
+
+ +
+

Combo Defaults

+ Global combo configuration +
+
+ {/* Default Strategy */} +
+
+

Default Strategy

+

+ Applied to new combos without explicit strategy +

+
+
+ {["priority", "weighted", "round-robin"].map((s) => ( + + ))} +
+
+ + {/* Numeric settings */} +
+ {[ + { key: "maxRetries", label: "Max Retries", min: 0, max: 5 }, + { key: "retryDelayMs", label: "Retry Delay (ms)", min: 500, max: 10000, step: 500 }, + { key: "timeoutMs", label: "Timeout (ms)", min: 5000, max: 300000, step: 5000 }, + { key: "maxComboDepth", label: "Max Nesting Depth", min: 1, max: 10 }, + ].map(({ key, label, min, max, step }) => ( + + setComboDefaults((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 })) + } + className="text-sm" + /> + ))} +
+ + {/* Round-Robin specific */} + {comboDefaults.strategy === "round-robin" && ( +
+ + setComboDefaults((prev) => ({ + ...prev, + concurrencyPerModel: parseInt(e.target.value) || 0, + })) + } + className="text-sm" + /> + + setComboDefaults((prev) => ({ + ...prev, + queueTimeoutMs: parseInt(e.target.value) || 0, + })) + } + className="text-sm" + /> +
+ )} + + {/* Toggles */} +
+
+
+

Health Check

+

Pre-check provider availability

+
+ + setComboDefaults((prev) => ({ + ...prev, + healthCheckEnabled: !prev.healthCheckEnabled, + })) + } + /> +
+
+
+

Track Metrics

+

Record per-combo request metrics

+
+ + setComboDefaults((prev) => ({ ...prev, trackMetrics: !prev.trackMetrics })) + } + /> +
+
+ + {/* Provider Overrides */} +
+

Provider Overrides

+

+ Override timeout and retries per provider. Provider settings override global defaults. +

+ + {Object.entries(providerOverrides).map(([provider, config]) => ( +
+ {provider} + + setProviderOverrides((prev) => ({ + ...prev, + [provider]: { ...prev[provider], maxRetries: parseInt(e.target.value) || 0 }, + })) + } + className="text-xs w-16" + aria-label={`${provider} max retries`} + /> + retries + + setProviderOverrides((prev) => ({ + ...prev, + [provider]: { + ...prev[provider], + timeoutMs: parseInt(e.target.value) || 120000, + }, + })) + } + className="text-xs w-24" + aria-label={`${provider} timeout ms`} + /> + ms + +
+ ))} + +
+ setNewOverrideProvider(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addProviderOverride()} + className="text-xs flex-1" + aria-label="New provider name" + /> + +
+
+ + {/* Save */} +
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.js b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.js new file mode 100644 index 0000000000..255771a118 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.js @@ -0,0 +1,72 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, ProxyConfigModal } from "@/shared/components"; + +export default function ProxyTab() { + const [proxyModalOpen, setProxyModalOpen] = useState(false); + const [globalProxy, setGlobalProxy] = useState(null); + + const loadGlobalProxy = async () => { + try { + const res = await fetch("/api/settings/proxy?level=global"); + if (res.ok) { + const data = await res.json(); + setGlobalProxy(data.proxy || null); + } + } catch {} + }; + + useEffect(() => { + loadGlobalProxy(); + }, []); + + return ( + <> + +
+
+ +

Global Proxy

+
+

+ Configure a global outbound proxy for all API calls. Individual providers, combos, and + keys can override this. +

+
+ {globalProxy ? ( +
+ + {globalProxy.type}://{globalProxy.host}:{globalProxy.port} + +
+ ) : ( + No global proxy configured + )} + +
+
+
+ + setProxyModalOpen(false)} + level="global" + levelLabel="Global" + onSaved={loadGlobalProxy} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js new file mode 100644 index 0000000000..00a626985f --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Input, Toggle } from "@/shared/components"; + +export default function RoutingTab() { + const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const updateFallbackStrategy = async (strategy) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fallbackStrategy: strategy }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, fallbackStrategy: strategy })); + } + } catch (err) { + console.error("Failed to update settings:", err); + } + }; + + const updateStickyLimit = async (limit) => { + const numLimit = parseInt(limit); + if (isNaN(numLimit) || numLimit < 1) return; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: numLimit })); + } + } catch (err) { + console.error("Failed to update sticky limit:", err); + } + }; + + return ( + +
+
+ +
+

Routing Strategy

+
+
+
+
+

Round Robin

+

Cycle through accounts to distribute load

+
+ + updateFallbackStrategy( + settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin" + ) + } + disabled={loading} + /> +
+ + {settings.fallbackStrategy === "round-robin" && ( +
+
+

Sticky Limit

+

Calls per account before switching

+
+ updateStickyLimit(e.target.value)} + disabled={loading} + className="w-20 text-center" + /> +
+ )} + +

+ {settings.fallbackStrategy === "round-robin" + ? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.` + : "Currently using accounts in priority order (Fill First)."} +

+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js new file mode 100644 index 0000000000..1102ad0e86 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input, Toggle } from "@/shared/components"; + +export default function SecurityTab() { + const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false }); + const [loading, setLoading] = useState(true); + const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); + const [passStatus, setPassStatus] = useState({ type: "", message: "" }); + const [passLoading, setPassLoading] = useState(false); + + useEffect(() => { + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const updateRequireLogin = async (requireLogin) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireLogin }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, requireLogin })); + } + } catch (err) { + console.error("Failed to update require login:", err); + } + }; + + const handlePasswordChange = async (e) => { + e.preventDefault(); + if (passwords.new !== passwords.confirm) { + setPassStatus({ type: "error", message: "Passwords do not match" }); + return; + } + + setPassLoading(true); + setPassStatus({ type: "", message: "" }); + + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: passwords.current, + newPassword: passwords.new, + }), + }); + const data = await res.json(); + if (res.ok) { + setPassStatus({ type: "success", message: "Password updated successfully" }); + setPasswords({ current: "", new: "", confirm: "" }); + } else { + setPassStatus({ type: "error", message: data.error || "Failed to update password" }); + } + } catch { + setPassStatus({ type: "error", message: "An error occurred" }); + } finally { + setPassLoading(false); + } + }; + + return ( + +
+
+ +
+

Security

+
+
+
+
+

Require login

+

+ When ON, dashboard requires password. When OFF, access without login. +

+
+ updateRequireLogin(!settings.requireLogin)} + disabled={loading} + /> +
+ {settings.requireLogin === true && ( +
+ {settings.hasPassword && ( + setPasswords({ ...passwords, current: e.target.value })} + required + /> + )} +
+ setPasswords({ ...passwords, new: e.target.value })} + required + /> + setPasswords({ ...passwords, confirm: e.target.value })} + required + /> +
+ + {passStatus.message && ( +

+ {passStatus.message} +

+ )} + +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.js b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.js new file mode 100644 index 0000000000..125c9d008b --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.js @@ -0,0 +1,375 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Badge } from "@/shared/components"; + +export default function SystemStorageTab() { + const [backups, setBackups] = useState([]); + const [backupsLoading, setBackupsLoading] = useState(false); + const [backupsExpanded, setBackupsExpanded] = useState(false); + const [restoreStatus, setRestoreStatus] = useState({ type: "", message: "" }); + const [restoringId, setRestoringId] = useState(null); + const [confirmRestoreId, setConfirmRestoreId] = useState(null); + const [manualBackupLoading, setManualBackupLoading] = useState(false); + const [manualBackupStatus, setManualBackupStatus] = useState({ type: "", message: "" }); + const [storageHealth, setStorageHealth] = useState({ + driver: "sqlite", + dbPath: "~/.omniroute/storage.sqlite", + sizeBytes: 0, + retentionDays: 90, + lastBackupAt: null, + }); + + const loadBackups = async () => { + setBackupsLoading(true); + try { + const res = await fetch("/api/db-backups"); + const data = await res.json(); + setBackups(data.backups || []); + } catch (err) { + console.error("Failed to fetch backups:", err); + } finally { + setBackupsLoading(false); + } + }; + + const loadStorageHealth = async () => { + try { + const res = await fetch("/api/storage/health"); + if (!res.ok) return; + const data = await res.json(); + setStorageHealth((prev) => ({ ...prev, ...data })); + } catch (err) { + console.error("Failed to fetch storage health:", err); + } + }; + + const handleManualBackup = async () => { + setManualBackupLoading(true); + setManualBackupStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/db-backups", { method: "PUT" }); + const data = await res.json(); + if (res.ok) { + if (data.filename) { + setManualBackupStatus({ type: "success", message: `Backup created: ${data.filename}` }); + } else { + setManualBackupStatus({ + type: "info", + message: data.message || "No changes since last backup", + }); + } + await loadStorageHealth(); + if (backupsExpanded) await loadBackups(); + } else { + setManualBackupStatus({ type: "error", message: data.error || "Backup failed" }); + } + } catch { + setManualBackupStatus({ type: "error", message: "An error occurred" }); + } finally { + setManualBackupLoading(false); + } + }; + + const handleRestore = async (backupId) => { + setRestoringId(backupId); + setRestoreStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/db-backups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ backupId }), + }); + const data = await res.json(); + if (res.ok) { + setRestoreStatus({ + type: "success", + message: `Restored! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`, + }); + await loadBackups(); + await loadStorageHealth(); + } else { + setRestoreStatus({ type: "error", message: data.error || "Restore failed" }); + } + } catch { + setRestoreStatus({ type: "error", message: "An error occurred during restore" }); + } finally { + setRestoringId(null); + setConfirmRestoreId(null); + } + }; + + useEffect(() => { + loadStorageHealth(); + }, []); + + const formatBytes = (bytes) => { + if (!bytes || bytes === 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + const formatRelativeTime = (isoString) => { + if (!isoString) return null; + const now = new Date(); + const then = new Date(isoString); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + return `${diffDays}d ago`; + }; + + return ( + +
+
+ +
+
+

System & Storage

+

All data stored locally on your machine

+
+ + {storageHealth.driver || "json"} + +
+ + {/* Storage info grid */} +
+
+

Database Path

+

+ {storageHealth.dbPath || "~/.omniroute/storage.sqlite"} +

+
+
+

Database Size

+

{formatBytes(storageHealth.sizeBytes)}

+
+
+ + {/* Last backup + Backup Now */} +
+
+ +
+

Last Backup

+

+ {storageHealth.lastBackupAt + ? `${new Date(storageHealth.lastBackupAt).toLocaleString("pt-BR")} (${formatRelativeTime(storageHealth.lastBackupAt)})` + : "No backup yet"} +

+
+
+ +
+ + {manualBackupStatus.message && ( +
+
+ + {manualBackupStatus.message} +
+
+ )} + + {/* Backup/Restore section */} +
+
+
+ +

Backup & Restore

+
+ +
+

+ Database snapshots are created automatically before restore and every 15 minutes when data + changes. Retention: 24 hourly + 30 daily backups with smart rotation. +

+ + {restoreStatus.message && ( +
+
+ + {restoreStatus.message} +
+
+ )} + + {backupsExpanded && ( +
+ {backupsLoading ? ( +
+ + Loading backups... +
+ ) : backups.length === 0 ? ( +
+ + No backups available yet. Backups will be created automatically when data changes. +
+ ) : ( + <> +
+ + {backups.length} backup(s) available + + +
+ {backups.map((backup) => ( +
+
+
+ + + {new Date(backup.createdAt).toLocaleString("pt-BR")} + + + {backup.reason} + +
+
+ {backup.connectionCount} connection(s) + + {formatBytes(backup.size)} +
+
+
+ {confirmRestoreId === backup.id ? ( + <> + Confirm? + + + + ) : ( + + )} +
+
+ ))} + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.js b/src/app/(dashboard)/dashboard/settings/page.js new file mode 100644 index 0000000000..45dd9cf064 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/page.js @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/shared/utils/cn"; +import { APP_CONFIG } from "@/shared/constants/config"; +import SystemStorageTab from "./components/SystemStorageTab"; +import SecurityTab from "./components/SecurityTab"; +import RoutingTab from "./components/RoutingTab"; +import ComboDefaultsTab from "./components/ComboDefaultsTab"; +import ProxyTab from "./components/ProxyTab"; +import AppearanceTab from "./components/AppearanceTab"; + +const tabs = [ + { id: "general", label: "General", icon: "settings" }, + { id: "security", label: "Security", icon: "shield" }, + { id: "routing", label: "Routing", icon: "route" }, + { id: "advanced", label: "Advanced", icon: "tune" }, +]; + +export default function SettingsPage() { + const [activeTab, setActiveTab] = useState("general"); + + return ( +
+
+ {/* Tab navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab contents */} +
t.id === activeTab)?.label}> + {activeTab === "general" && ( + <> +
+ + +
+ + )} + + {activeTab === "security" && } + + {activeTab === "routing" && ( +
+ + +
+ )} + + {activeTab === "advanced" && } +
+ + {/* App Info */} +
+

+ {APP_CONFIG.name} v{APP_CONFIG.version} +

+

Local Mode — All data stored on your machine

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js new file mode 100644 index 0000000000..0a505bc4c1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.js @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { SegmentedControl } from "@/shared/components"; +import PlaygroundMode from "./components/PlaygroundMode"; +import ChatTesterMode from "./components/ChatTesterMode"; +import TestBenchMode from "./components/TestBenchMode"; +import LiveMonitorMode from "./components/LiveMonitorMode"; + +const MODES = [ + { value: "playground", label: "Playground", icon: "code" }, + { value: "chat-tester", label: "Chat Tester", icon: "chat" }, + { value: "test-bench", label: "Test Bench", icon: "science" }, + { value: "live-monitor", label: "Live Monitor", icon: "monitoring" }, +]; + +export default function TranslatorPageClient() { + const [mode, setMode] = useState("playground"); + + return ( +
+ {/* Header */} +
+
+

+ translate + Translator Playground +

+

+ Debug, test, and visualize API format translations +

+
+ +
+ + {/* Mode Content */} + {mode === "playground" && } + {mode === "chat-tester" && } + {mode === "test-bench" && } + {mode === "live-monitor" && } +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js new file mode 100644 index 0000000000..c6bfaee455 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.js @@ -0,0 +1,507 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, Select, Badge } from "@/shared/components"; +import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates"; +import { + AI_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; +import dynamic from "next/dynamic"; + +const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); + +/** + * Chat Tester Mode: + * - Left: Chat interface (send messages as a specific client format) + * - Right: Pipeline visualization showing each translation step + */ +export default function ChatTesterMode() { + const [provider, setProvider] = useState("openai"); + const [providerOptions, setProviderOptions] = useState([]); + const [clientFormat, setClientFormat] = useState("openai"); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [chatHistory, setChatHistory] = useState([]); + const [pipeline, setPipeline] = useState(null); + const [expandedStep, setExpandedStep] = useState(null); + const messagesEndRef = useRef(null); + + // Load providers + useEffect(() => { + const fetchProviders = async () => { + try { + const [connRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]); + const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n])); + const activeProviders = new Set( + (connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider) + ); + const options = [...activeProviders] + .map((pid) => { + const info = AI_PROVIDERS[pid]; + const node = nodeMap.get(pid); + let label = info?.name || node?.name || pid; + if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX)) + label = node?.name || "OpenAI Compatible"; + if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + label = node?.name || "Anthropic Compatible"; + return { value: pid, label }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + const nextOptions = + options.length > 0 + ? options + : Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name })); + setProviderOptions(nextOptions); + if (nextOptions.length > 0) { + setProvider((current) => + nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value + ); + } + } catch { + const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({ + value: id, + label: info.name, + })); + setProviderOptions(fallbackOptions); + if (fallbackOptions.length > 0) { + setProvider((current) => + fallbackOptions.some((opt) => opt.value === current) + ? current + : fallbackOptions[0].value + ); + } + } + }; + fetchProviders(); + }, []); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + const handleSend = async () => { + if (!message.trim() || sending) return; + + const userMessage = message.trim(); + setMessage(""); + setSending(true); + setChatHistory((prev) => [...prev, { role: "user", content: userMessage }]); + + const steps = []; + + try { + // Build the messages array + const allMessages = [ + ...chatHistory.map((m) => ({ role: m.role, content: m.content })), + { role: "user", content: userMessage }, + ]; + + // Step 1: Build client request in the chosen format + let clientRequest; + if (clientFormat === "claude") { + clientRequest = { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: allMessages, + stream: true, + }; + } else if (clientFormat === "gemini") { + clientRequest = { + model: "gemini-2.5-flash", + contents: allMessages.map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })), + }; + } else if (clientFormat === "openai-responses") { + clientRequest = { + model: "gpt-4o", + input: allMessages.map((m) => ({ + type: "message", + role: m.role, + content: [{ type: "input_text", text: m.content }], + })), + stream: true, + }; + } else { + clientRequest = { + model: "gpt-4o", + messages: allMessages, + stream: true, + }; + } + + steps.push({ + id: 1, + name: "Client Request", + format: clientFormat, + content: JSON.stringify(clientRequest, null, 2), + status: "done", + }); + + // Step 2: Detect source format + const detectRes = await fetch("/api/translator/detect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: clientRequest }), + }); + const detectData = await detectRes.json(); + const detectedFormat = detectData.format || clientFormat; + + steps.push({ + id: 2, + name: "Format Detected", + format: detectedFormat, + content: JSON.stringify( + { detectedFormat, clientFormat, match: detectedFormat === clientFormat }, + null, + 2 + ), + status: "done", + }); + + // Step 3: Translate to OpenAI intermediate + const toOpenaiRes = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: detectedFormat, + targetFormat: "openai", + body: clientRequest, + }), + }); + const toOpenaiData = await toOpenaiRes.json(); + + steps.push({ + id: 3, + name: "OpenAI Intermediate", + format: "openai", + content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2), + status: toOpenaiData.success ? "done" : "error", + }); + + // Step 4: Translate to provider target format + const providerTargetRes = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: "openai", + provider, + body: toOpenaiData.result, + }), + }); + const providerTargetData = await providerTargetRes.json(); + const targetFmt = providerTargetData.targetFormat || "openai"; + + steps.push({ + id: 4, + name: "Provider Format", + format: targetFmt, + content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2), + status: providerTargetData.success ? "done" : "error", + }); + + // Step 5: Send to provider (use the OpenAI intermediate since the proxy handles translation) + const sendRes = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, body: providerTargetData.result || toOpenaiData.result }), + }); + + if (!sendRes.ok) { + const errData = await sendRes.json().catch(() => ({ error: "Request failed" })); + steps.push({ + id: 5, + name: "Provider Response", + format: targetFmt, + content: JSON.stringify(errData, null, 2), + status: "error", + }); + setChatHistory((prev) => [ + ...prev, + { role: "assistant", content: `Error: ${errData.error || "Request failed"}` }, + ]); + } else { + // Read streaming response + const reader = sendRes.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + fullResponse += decoder.decode(value, { stream: true }); + } + + steps.push({ + id: 5, + name: "Provider Response", + format: targetFmt, + content: + fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""), + status: "done", + }); + + // Extract assistant text from SSE + const assistantText = extractAssistantText(fullResponse); + setChatHistory((prev) => [ + ...prev, + { role: "assistant", content: assistantText || "(No text extracted)" }, + ]); + } + } catch (err) { + steps.push({ + id: steps.length + 1, + name: "Error", + format: "error", + content: JSON.stringify({ error: err.message }, null, 2), + status: "error", + }); + setChatHistory((prev) => [...prev, { role: "assistant", content: `Error: ${err.message}` }]); + } + + setPipeline(steps); + setExpandedStep(steps.length > 0 ? steps[steps.length - 1].id : null); + setSending(false); + setTimeout(scrollToBottom, 100); + }; + + return ( +
+ {/* Left: Chat Interface */} +
+ {/* Controls */} + +
+
+ + setProvider(e.target.value)} + options={providerOptions} + /> +
+
+
+ + {/* Chat Messages */} + +
+ {chatHistory.length === 0 && ( +
+ chat +

Send a message to see the translation pipeline

+
+ )} + {chatHistory.map((msg, i) => ( +
+
+

+ {msg.role === "user" + ? `You (${FORMAT_META[clientFormat]?.label})` + : "Assistant"} +

+

{msg.content}

+
+
+ ))} +
+
+ + {/* Input */} +
+
+ setMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} + placeholder="Type a message..." + className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" + disabled={sending} + /> + +
+
+ +
+ + {/* Right: Pipeline Visualization */} +
+ +
+
+ + account_tree + +

Translation Pipeline

+
+

Click on any step to inspect the data

+
+
+ + {!pipeline ? ( + +
+ + account_tree + +

Send a message to see the pipeline

+
+
+ ) : ( +
+ {pipeline.map((step, i) => { + const meta = FORMAT_META[step.format] || { + label: step.format, + color: "gray", + icon: "code", + }; + const isExpanded = expandedStep === step.id; + + return ( +
+ {/* Connector line */} + {i > 0 && ( +
+
+
+ )} + + + + + {/* Expanded content */} + {isExpanded && ( +
+
+ +
+
+ )} +
+
+ ); + })} +
+ )} +
+
+ ); +} + +/** Extract assistant text from SSE stream */ +function extractAssistantText(sseText) { + let text = ""; + const lines = sseText.split("\n"); + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") break; + try { + const parsed = JSON.parse(payload); + // OpenAI format + const delta = parsed.choices?.[0]?.delta; + if (delta?.content) text += delta.content; + // Claude format + if (parsed.type === "content_block_delta" && parsed.delta?.text) { + text += parsed.delta.text; + } + } catch { + /* not JSON, skip */ + } + } + return text || sseText.slice(0, 500); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js new file mode 100644 index 0000000000..69d66bdad0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/LiveMonitorMode.js @@ -0,0 +1,201 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Badge } from "@/shared/components"; +import { FORMAT_META } from "../exampleTemplates"; + +/** + * Live Monitor Mode: + * Shows recent translation activity from the proxy in real-time. + * Polls /api/translator/history for translation events. + */ +export default function LiveMonitorMode() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [autoRefresh, setAutoRefresh] = useState(true); + const intervalRef = useRef(null); + + const fetchHistory = async () => { + try { + const res = await fetch("/api/translator/history?limit=50"); + if (res.ok) { + const data = await res.json(); + setEvents(data.events || []); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchHistory(); + if (autoRefresh) { + intervalRef.current = setInterval(fetchHistory, 3000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh]); + + // Stats + const successCount = events.filter((e) => e.status === "success").length; + const errorCount = events.filter((e) => e.status === "error").length; + const avgLatency = + events.length > 0 + ? Math.round(events.reduce((sum, e) => sum + (e.latency || 0), 0) / events.length) + : 0; + + return ( +
+ {/* Stats Cards */} +
+ + + + +
+ + {/* Controls */} + +
+
+ + {autoRefresh ? "radio_button_checked" : "radio_button_unchecked"} + + +
+ +
+
+ + {/* Events Table */} + +
+

Recent Translations

+ + {loading ? ( +
+ progress_activity + Loading... +
+ ) : events.length === 0 ? ( +
+ + monitoring + +

No translations yet

+

+ Translations will appear here as requests flow through the proxy. +

+

+ Make API calls to your OmniRoute endpoints to see live translation data. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {events.map((event, i) => { + const srcMeta = FORMAT_META[event.sourceFormat] || { + label: event.sourceFormat || "?", + color: "gray", + }; + const tgtMeta = FORMAT_META[event.targetFormat] || { + label: event.targetFormat || "?", + color: "gray", + }; + + return ( + + + + + + + + + + ); + })} + +
TimeSourceTargetModelStatusLatency
+ {event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : "—"} + + + {srcMeta.label} + + + + arrow_forward + + + + {tgtMeta.label} + + + {event.model || "—"} + + {event.status === "success" ? ( + + OK + + ) : ( + + {event.statusCode || "ERR"} + + )} + + {event.latency ? `${event.latency}ms` : "—"} +
+
+ )} +
+
+
+ ); +} + +function StatCard({ icon, label, value, color }) { + return ( + +
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js b/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js new file mode 100644 index 0000000000..a2a9c2ba11 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/PlaygroundMode.js @@ -0,0 +1,345 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Card, Button, Select, Badge } from "@/shared/components"; +import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates"; +import dynamic from "next/dynamic"; + +const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); + +export default function PlaygroundMode() { + const [sourceFormat, setSourceFormat] = useState("claude"); + const [targetFormat, setTargetFormat] = useState("openai"); + const [inputContent, setInputContent] = useState(""); + const [outputContent, setOutputContent] = useState(""); + const [detectedFormat, setDetectedFormat] = useState(null); + const [translating, setTranslating] = useState(false); + const [detecting, setDetecting] = useState(false); + const [activeTemplate, setActiveTemplate] = useState(null); + + // Auto-detect format when input changes + const detectFormatFromInput = useCallback(async (content) => { + if (!content || content.trim().length < 5) { + setDetectedFormat(null); + return; + } + try { + const parsed = JSON.parse(content); + setDetecting(true); + const res = await fetch("/api/translator/detect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: parsed }), + }); + const data = await res.json(); + if (data.success) { + setDetectedFormat(data.format); + setSourceFormat(data.format); + } + } catch { + // Not valid JSON yet, ignore + } finally { + setDetecting(false); + } + }, []); + + // Debounced auto-detect + useEffect(() => { + const timer = setTimeout(() => { + detectFormatFromInput(inputContent); + }, 600); + return () => clearTimeout(timer); + }, [inputContent, detectFormatFromInput]); + + const handleTranslate = async () => { + if (!inputContent.trim()) return; + + setTranslating(true); + setOutputContent(""); + try { + const parsed = JSON.parse(inputContent); + const res = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat, + targetFormat, + provider: + targetFormat === "claude" + ? "anthropic" + : targetFormat === "gemini" + ? "google" + : "openai", + body: parsed, + }), + }); + const data = await res.json(); + if (data.success) { + setOutputContent(JSON.stringify(data.result, null, 2)); + } else { + setOutputContent(JSON.stringify({ error: data.error }, null, 2)); + } + } catch (err) { + setOutputContent(JSON.stringify({ error: err.message }, null, 2)); + } + setTranslating(false); + }; + + const loadTemplate = (template) => { + const formatData = template.formats[sourceFormat] || template.formats.openai; + setInputContent(JSON.stringify(formatData, null, 2)); + setActiveTemplate(template.id); + setOutputContent(""); + }; + + const handleCopy = async (text) => { + try { + await navigator.clipboard.writeText(text); + } catch { + /* silent */ + } + }; + + const handleSwapFormats = () => { + setSourceFormat(targetFormat); + setTargetFormat(sourceFormat); + setInputContent(outputContent); + setOutputContent(""); + setDetectedFormat(null); + }; + + const srcMeta = FORMAT_META[sourceFormat] || FORMAT_META.openai; + const tgtMeta = FORMAT_META[targetFormat] || FORMAT_META.openai; + + return ( +
+ {/* Format Controls Bar */} + +
+ {/* Source Format */} +
+ +
+ + {srcMeta.icon} + + setTargetFormat(e.target.value)} + options={FORMAT_OPTIONS} + className="flex-1" + /> +
+
+ + {/* Translate Button */} +
+ +
+
+
+ + {/* Split Editor View */} +
+ {/* Input Panel */} + +
+
+
+ input +

Input

+ {detectedFormat && ( + + {FORMAT_META[detectedFormat]?.label || detectedFormat} + + )} + {detecting && ( + + progress_activity + + )} +
+
+ + +
+
+
+ setInputContent(value || "")} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + formatOnPaste: true, + placeholder: "Paste a request body here or select a template below...", + }} + /> +
+
+
+ + {/* Output Panel */} + +
+
+
+ + output + +

Output

+ {outputContent && ( + + {FORMAT_META[targetFormat]?.label || targetFormat} + + )} +
+
+ +
+
+
+ +
+
+
+
+ + {/* Example Templates */} + +
+
+ + library_books + +

Example Templates

+ — Click to load +
+
+ {EXAMPLE_TEMPLATES.map((template) => ( + + ))} +
+ {activeTemplate && ( +
+ info + Template loads the request in{" "} + + {FORMAT_META[sourceFormat]?.label || sourceFormat} + {" "} + format. Change Source Format to load in a different format. +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js b/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js new file mode 100644 index 0000000000..4650e9a198 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/TestBenchMode.js @@ -0,0 +1,349 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Select, Badge } from "@/shared/components"; +import { EXAMPLE_TEMPLATES, FORMAT_META } from "../exampleTemplates"; +import { + AI_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; + +/** + * Test Bench Mode: + * Run translation + send scenarios between providers to validate compatibility. + */ + +const SCENARIOS = [ + { id: "simple-chat", name: "Simple Chat", icon: "chat", templateId: "simple-chat" }, + { id: "tool-calling", name: "Tool Calling", icon: "build", templateId: "tool-calling" }, + { id: "multi-turn", name: "Multi-turn", icon: "forum", templateId: "multi-turn" }, + { id: "thinking", name: "Thinking", icon: "psychology", templateId: "thinking" }, + { id: "system-prompt", name: "System Prompt", icon: "settings", templateId: "system-prompt" }, + { id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" }, +]; + +export default function TestBenchMode() { + const [sourceFormat, setSourceFormat] = useState("claude"); + const [provider, setProvider] = useState("openai"); + const [providerOptions, setProviderOptions] = useState([]); + const [results, setResults] = useState({}); + const [runningAll, setRunningAll] = useState(false); + + useEffect(() => { + const fetchProviders = async () => { + try { + const [connRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]); + const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n])); + const activeProviders = new Set( + (connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider) + ); + const options = [...activeProviders] + .map((pid) => { + const info = AI_PROVIDERS[pid]; + const node = nodeMap.get(pid); + let label = info?.name || node?.name || pid; + if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX)) + label = node?.name || "OpenAI Compatible"; + if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + label = node?.name || "Anthropic Compatible"; + return { value: pid, label }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + const nextOptions = + options.length > 0 + ? options + : Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name })); + setProviderOptions(nextOptions); + if (nextOptions.length > 0) { + setProvider((current) => + nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value + ); + } + } catch { + const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({ + value: id, + label: info.name, + })); + setProviderOptions(fallbackOptions); + if (fallbackOptions.length > 0) { + setProvider((current) => + fallbackOptions.some((opt) => opt.value === current) + ? current + : fallbackOptions[0].value + ); + } + } + }; + fetchProviders(); + }, []); + + const runScenario = async (scenario) => { + setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } })); + + const start = Date.now(); + try { + // Find template + const template = EXAMPLE_TEMPLATES.find((t) => t.id === scenario.templateId); + const body = template?.formats[sourceFormat] || template?.formats.openai; + + if (!body) { + setResults((prev) => ({ + ...prev, + [scenario.id]: { status: "error", error: "No template for this format", latency: 0 }, + })); + return; + } + + // Step 1: Translate + const translateRes = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "direct", sourceFormat, provider, body }), + }); + const translateData = await translateRes.json(); + + if (!translateData.success) { + setResults((prev) => ({ + ...prev, + [scenario.id]: { + status: "error", + error: `Translation failed: ${translateData.error}`, + latency: Date.now() - start, + }, + })); + return; + } + + // Step 2: Send to provider + const sendRes = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, body: translateData.result }), + }); + + const latency = Date.now() - start; + + if (!sendRes.ok) { + const errData = await sendRes.json().catch(() => ({})); + setResults((prev) => ({ + ...prev, + [scenario.id]: { + status: "error", + error: errData.error || `HTTP ${sendRes.status}`, + latency, + httpStatus: sendRes.status, + }, + })); + return; + } + + // Read response to consume stream + const reader = sendRes.body.getReader(); + let chunks = 0; + while (true) { + const { done } = await reader.read(); + if (done) break; + chunks++; + } + + setResults((prev) => ({ + ...prev, + [scenario.id]: { status: "pass", latency: Date.now() - start, chunks }, + })); + } catch (err) { + setResults((prev) => ({ + ...prev, + [scenario.id]: { status: "error", error: err.message, latency: Date.now() - start }, + })); + } + }; + + const handleRunAll = async () => { + setRunningAll(true); + setResults({}); + for (const scenario of SCENARIOS) { + await runScenario(scenario); + } + setRunningAll(false); + }; + + const passCount = Object.values(results).filter((r) => r.status === "pass").length; + const failCount = Object.values(results).filter((r) => r.status === "error").length; + const totalRun = passCount + failCount; + const compatibility = totalRun > 0 ? Math.round((passCount / totalRun) * 100) : 0; + const srcMeta = FORMAT_META[sourceFormat] || FORMAT_META.openai; + + return ( +
+ {/* Controls */} + +
+
+ + { + setProvider(e.target.value); + setResults({}); + }} + options={providerOptions} + /> +
+ +
+
+ + {/* Results summary bar */} + {totalRun > 0 && ( + +
+
+
+

Compatibility Report

+ = 80 ? "success" : compatibility >= 50 ? "warning" : "error" + } + size="lg" + > + {compatibility}% + +
+
+ + {passCount} passed + + + {failCount} failed + +
+
+ {/* Progress bar */} +
+
+
+
+ + )} + + {/* Scenario cards */} +
+ {SCENARIOS.map((scenario) => { + const result = results[scenario.id]; + const isRunning = result?.status === "running"; + + return ( + +
+
+
+
+ + {isRunning + ? "progress_activity" + : result?.status === "pass" + ? "check_circle" + : result?.status === "error" + ? "error" + : scenario.icon} + +
+
+

{scenario.name}

+

+ {srcMeta.label} →{" "} + {providerOptions.find((o) => o.value === provider)?.label || provider} +

+
+
+
+ + {/* Result details */} + {result && result.status !== "running" && ( +
+ {result.status === "pass" ? ( +
+ ✅ Passed + + {result.latency}ms • {result.chunks} chunks + +
+ ) : ( +
+

❌ {result.error}

+

{result.latency}ms

+
+ )} +
+ )} + + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/exampleTemplates.js b/src/app/(dashboard)/dashboard/translator/exampleTemplates.js new file mode 100644 index 0000000000..254bba5f3d --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/exampleTemplates.js @@ -0,0 +1,315 @@ +/** + * Example templates for the Translator Playground. + * Each template provides request bodies in multiple formats so users can + * quickly load a realistic payload and see how the translator converts it. + */ + +export const EXAMPLE_TEMPLATES = [ + { + id: "simple-chat", + name: "Simple Chat", + icon: "chat", + description: "Basic text message", + formats: { + openai: { + model: "gpt-4o", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello! How are you today?" }, + ], + stream: true, + }, + claude: { + model: "claude-sonnet-4-20250514", + system: "You are a helpful assistant.", + max_tokens: 1024, + messages: [{ role: "user", content: "Hello! How are you today?" }], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { + role: "user", + parts: [{ text: "Hello! How are you today?" }], + }, + ], + systemInstruction: { + parts: [{ text: "You are a helpful assistant." }], + }, + }, + "openai-responses": { + model: "gpt-4o", + input: "Hello! How are you today?", + instructions: "You are a helpful assistant.", + }, + }, + }, + { + id: "tool-calling", + name: "Tool Calling", + icon: "build", + description: "Function/tool invocation", + formats: { + openai: { + model: "gpt-4o", + messages: [{ role: "user", content: "What's the weather in São Paulo?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string", description: "City name" }, + unit: { type: "string", enum: ["celsius", "fahrenheit"] }, + }, + required: ["location"], + }, + }, + }, + ], + stream: true, + }, + claude: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [{ role: "user", content: "What's the weather in São Paulo?" }], + tools: [ + { + name: "get_weather", + description: "Get current weather for a location", + input_schema: { + type: "object", + properties: { + location: { type: "string", description: "City name" }, + unit: { type: "string", enum: ["celsius", "fahrenheit"] }, + }, + required: ["location"], + }, + }, + ], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { + role: "user", + parts: [{ text: "What's the weather in São Paulo?" }], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string", description: "City name" }, + unit: { type: "string", enum: ["celsius", "fahrenheit"] }, + }, + required: ["location"], + }, + }, + ], + }, + ], + }, + }, + }, + { + id: "multi-turn", + name: "Multi-turn", + icon: "forum", + description: "Conversation with history", + formats: { + openai: { + model: "gpt-4o", + messages: [ + { role: "system", content: "You are a coding assistant." }, + { role: "user", content: "Write a function to sort an array in Python." }, + { + role: "assistant", + content: + "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```", + }, + { role: "user", content: "Now make it sort in descending order." }, + ], + stream: true, + }, + claude: { + model: "claude-sonnet-4-20250514", + system: "You are a coding assistant.", + max_tokens: 1024, + messages: [ + { role: "user", content: "Write a function to sort an array in Python." }, + { + role: "assistant", + content: + "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```", + }, + { role: "user", content: "Now make it sort in descending order." }, + ], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { role: "user", parts: [{ text: "Write a function to sort an array in Python." }] }, + { + role: "model", + parts: [ + { + text: "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```", + }, + ], + }, + { role: "user", parts: [{ text: "Now make it sort in descending order." }] }, + ], + systemInstruction: { + parts: [{ text: "You are a coding assistant." }], + }, + }, + }, + }, + { + id: "thinking", + name: "Thinking", + icon: "psychology", + description: "Extended thinking / reasoning", + formats: { + openai: { + model: "o3-mini", + messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }], + stream: true, + }, + claude: { + model: "claude-sonnet-4-20250514", + max_tokens: 16000, + thinking: { + type: "enabled", + budget_tokens: 10000, + }, + messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash-thinking", + contents: [ + { role: "user", parts: [{ text: "What is the sum of the first 100 prime numbers?" }] }, + ], + generationConfig: { + thinkingConfig: { + thinkingBudget: 10000, + }, + }, + }, + }, + }, + { + id: "system-prompt", + name: "System Prompt", + icon: "settings", + description: "Complex system instructions", + formats: { + openai: { + model: "gpt-4o", + messages: [ + { + role: "system", + content: + "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.", + }, + { role: "user", content: "How do I implement a circuit breaker pattern?" }, + ], + temperature: 0.7, + stream: true, + }, + claude: { + model: "claude-sonnet-4-20250514", + system: + "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.", + max_tokens: 2048, + messages: [{ role: "user", content: "How do I implement a circuit breaker pattern?" }], + temperature: 0.7, + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { role: "user", parts: [{ text: "How do I implement a circuit breaker pattern?" }] }, + ], + systemInstruction: { + parts: [ + { + text: "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.", + }, + ], + }, + generationConfig: { + temperature: 0.7, + }, + }, + }, + }, + { + id: "streaming", + name: "Streaming", + icon: "stream", + description: "SSE streaming request", + formats: { + openai: { + model: "gpt-4o", + messages: [ + { role: "user", content: "Tell me a short story about a robot learning to paint." }, + ], + stream: true, + stream_options: { include_usage: true }, + }, + claude: { + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [ + { role: "user", content: "Tell me a short story about a robot learning to paint." }, + ], + stream: true, + }, + gemini: { + model: "gemini-2.5-flash", + contents: [ + { + role: "user", + parts: [{ text: "Tell me a short story about a robot learning to paint." }], + }, + ], + }, + }, + }, +]; + +/** + * Format metadata for display: colors, labels, icons + */ +export const FORMAT_META = { + openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" }, + "openai-responses": { label: "OpenAI Responses", color: "amber", icon: "swap_horiz" }, + claude: { label: "Claude", color: "orange", icon: "psychology" }, + gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" }, + antigravity: { label: "Antigravity", color: "purple", icon: "rocket_launch" }, + kiro: { label: "Kiro", color: "cyan", icon: "terminal" }, + cursor: { label: "Cursor", color: "pink", icon: "edit" }, + codex: { label: "Codex", color: "yellow", icon: "code" }, +}; + +/** + * All format options for dropdowns + */ +export const FORMAT_OPTIONS = Object.entries(FORMAT_META).map(([value, meta]) => ({ + value, + label: meta.label, +})); diff --git a/src/app/(dashboard)/dashboard/translator/page.js b/src/app/(dashboard)/dashboard/translator/page.js new file mode 100644 index 0000000000..b7861263a3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/page.js @@ -0,0 +1,10 @@ +import TranslatorPageClient from "./TranslatorPageClient"; + +export const metadata = { + title: "Translator Playground | OmniRoute", + description: "Debug, test, and visualize API format translations between providers", +}; + +export default function TranslatorPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js new file mode 100644 index 0000000000..e7535f48ac --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import Card from "@/shared/components/Card"; +import Badge from "@/shared/components/Badge"; +import QuotaProgressBar from "./QuotaProgressBar"; +import { calculatePercentage } from "./utils"; + +const planVariants = { + free: "default", + pro: "primary", + ultra: "success", + enterprise: "info", +}; + +export default function ProviderLimitCard({ + provider, + name, + plan, + quotas = [], + message = null, + loading = false, + error = null, + onRefresh, +}) { + const [refreshing, setRefreshing] = useState(false); + const [imgError, setImgError] = useState(false); + + const handleRefresh = async () => { + if (!onRefresh || refreshing) return; + + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + }; + + // Get provider info from config + const getProviderColor = () => { + const colors = { + github: "#000000", + antigravity: "#4285F4", + codex: "#10A37F", + kiro: "#FF9900", + claude: "#D97757", + }; + return colors[provider?.toLowerCase()] || "#6B7280"; + }; + + const providerColor = getProviderColor(); + const planVariant = planVariants[plan?.toLowerCase()] || "default"; + + return ( + + {/* Header */} +
+
+ {/* Provider Logo */} +
+ {imgError ? ( + + {provider?.slice(0, 2).toUpperCase() || "PR"} + + ) : ( + {provider setImgError(true)} + /> + )} +
+ +
+

{name || provider}

+ {plan && ( + + {plan} + + )} +
+
+ + {/* Refresh Button */} + +
+ + {/* Loading State */} + {loading && ( +
+
+
+
+
+
+
+
+
+
+ )} + + {/* Error State */} + {!loading && error && ( +
+
+ error +

{error}

+
+
+ )} + + {/* Info Message (for providers without API) */} + {!loading && !error && message && ( +
+
+ info +

{message}

+
+
+ )} + + {/* Quota Progress Bars */} + {!loading && !error && !message && quotas?.length > 0 && ( +
+ {quotas.map((quota, index) => { + // For Antigravity, use remainingPercentage if available, otherwise calculate + const percentage = + quota.remainingPercentage !== undefined + ? Math.round(((quota.total - quota.used) / quota.total) * 100) + : calculatePercentage(quota.used, quota.total); + const unlimited = quota.total === 0 || quota.total === null; + + return ( + + ); + })} +
+ )} + + {/* Empty State */} + {!loading && !error && !message && quotas?.length === 0 && ( +
+ data_usage +

No quota data available

+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js new file mode 100644 index 0000000000..8885f9e202 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js @@ -0,0 +1,120 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; +import { formatResetTime } from "./utils"; + +// Calculate color based on remaining percentage +const getColorClasses = (remainingPercentage) => { + if (remainingPercentage > 70) { + return { + text: "text-green-500", + bg: "bg-green-500", + bgLight: "bg-green-500/10", + emoji: "🟢", + }; + } + + if (remainingPercentage >= 30) { + return { + text: "text-yellow-500", + bg: "bg-yellow-500", + bgLight: "bg-yellow-500/10", + emoji: "🟡", + }; + } + + // 0-29% including 0% (out of quota) - show red + return { + text: "text-red-500", + bg: "bg-red-500", + bgLight: "bg-red-500/10", + emoji: "🔴", + }; +}; + +// Format reset time display +const formatResetTimeDisplay = (resetTime) => { + if (!resetTime) return null; + + try { + const resetDate = new Date(resetTime); + const now = new Date(); + const isToday = resetDate.toDateString() === now.toDateString(); + const isTomorrow = + resetDate.toDateString() === new Date(now.getTime() + 86400000).toDateString(); + + const timeStr = resetDate.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + + if (isToday) return `Today, ${timeStr}`; + if (isTomorrow) return `Tomorrow, ${timeStr}`; + + return resetDate.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + } catch { + return null; + } +}; + +export default function QuotaProgressBar({ + percentage = 0, + label = "", + used = 0, + total = 0, + unlimited = false, + resetTime = null, +}) { + const colors = getColorClasses(percentage); + const countdown = formatResetTime(resetTime); + const resetDisplay = formatResetTimeDisplay(resetTime); + + // percentage is already remaining percentage (from ProviderLimitCard) + const remaining = percentage; + + return ( +
+ {/* Label and percentage */} +
+ {label} +
+ {colors.emoji} + {remaining}% +
+
+ + {/* Progress bar */} + {!unlimited && ( +
+
+
+ )} + + {/* Usage details and countdown */} +
+ + {used.toLocaleString()} / {total.toLocaleString()} requests + + {countdown !== "-" && ( +
+ + Reset in {countdown} +
+ )} +
+ + {/* Reset time display */} + {resetDisplay &&
Reset at {resetDisplay}
} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js new file mode 100644 index 0000000000..056adfbb4d --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -0,0 +1,160 @@ +"use client"; + +import { formatResetTime, calculatePercentage } from "./utils"; + +/** + * Format reset time display (Today, 12:00 PM) + */ +function formatResetTimeDisplay(resetTime) { + if (!resetTime) return null; + + try { + const date = new Date(resetTime); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + let dayStr = ""; + if (date >= today && date < tomorrow) { + dayStr = "Today"; + } else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) { + dayStr = "Tomorrow"; + } else { + dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } + + const timeStr = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + return `${dayStr}, ${timeStr}`; + } catch { + return null; + } +} + +/** + * Get color classes based on remaining percentage + */ +function getColorClasses(remainingPercentage) { + if (remainingPercentage > 70) { + return { + text: "text-green-600 dark:text-green-400", + bg: "bg-green-500", + bgLight: "bg-green-500/10", + emoji: "🟢", + }; + } + + if (remainingPercentage >= 30) { + return { + text: "text-yellow-600 dark:text-yellow-400", + bg: "bg-yellow-500", + bgLight: "bg-yellow-500/10", + emoji: "🟡", + }; + } + + // 0-29% including 0% (out of quota) - show red + return { + text: "text-red-600 dark:text-red-400", + bg: "bg-red-500", + bgLight: "bg-red-500/10", + emoji: "🔴", + }; +} + +/** + * Quota Table Component - Table-based display for quota data + */ +export default function QuotaTable({ quotas = [] }) { + if (!quotas || quotas.length === 0) { + return null; + } + + return ( +
+ + + {/* Model Name */} + {/* Limit Progress */} + {/* Reset Time */} + + + {quotas.map((quota, index) => { + const remaining = + quota.remainingPercentage !== undefined + ? Math.round(quota.remainingPercentage) + : calculatePercentage(quota.used, quota.total); + + const colors = getColorClasses(remaining); + const countdown = formatResetTime(quota.resetAt); + const resetDisplay = formatResetTimeDisplay(quota.resetAt); + + return ( + + {/* Model Name with Status Emoji */} + + + {/* Limit (Progress + Numbers) */} + + + {/* Reset Time */} + + + ); + })} + +
+
+ {colors.emoji} + {quota.name} +
+
+
+ {/* Progress bar - always show with border for visibility */} +
+
+
+ + {/* Numbers */} +
+ + {quota.used.toLocaleString()} /{" "} + {quota.total > 0 ? quota.total.toLocaleString() : "∞"} + + {remaining}% +
+
+
+ {countdown !== "-" || resetDisplay ? ( +
+ {countdown !== "-" && ( +
in {countdown}
+ )} + {resetDisplay && ( +
{resetDisplay}
+ )} +
+ ) : ( +
N/A
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js new file mode 100644 index 0000000000..9f609a99df --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -0,0 +1,534 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import Image from "next/image"; +import { parseQuotaData, calculatePercentage, normalizePlanTier } from "./utils"; +import Card from "@/shared/components/Card"; +import Badge from "@/shared/components/Badge"; +import { CardSkeleton } from "@/shared/components/Loading"; +import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; + +const REFRESH_INTERVAL_MS = 120000; +const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches + +// Provider display config +const PROVIDER_CONFIG = { + antigravity: { label: "Antigravity", color: "#F59E0B" }, + github: { label: "GitHub Copilot", color: "#333" }, + kiro: { label: "Kiro AI", color: "#FF6B35" }, + codex: { label: "OpenAI Codex", color: "#10A37F" }, + claude: { label: "Claude Code", color: "#D97757" }, +}; + +const TIER_FILTERS = [ + { key: "all", label: "All" }, + { key: "enterprise", label: "Enterprise" }, + { key: "business", label: "Business" }, + { key: "ultra", label: "Ultra" }, + { key: "pro", label: "Pro" }, + { key: "free", label: "Free" }, + { key: "unknown", label: "Unknown" }, +]; + +// Short model display names for quota bars +function getShortModelName(name) { + const map = { + "gemini-3-pro-high": "G3 Pro", + "gemini-3-pro-low": "G3 Pro Low", + "gemini-3-flash": "G3 Flash", + "gemini-2.5-flash": "G2.5 Flash", + "claude-opus-4-6-thinking": "Opus 4.6 Tk", + "claude-opus-4-5-thinking": "Opus 4.5 Tk", + "claude-opus-4-5": "Opus 4.5", + "claude-sonnet-4-5-thinking": "Sonnet 4.5 Tk", + "claude-sonnet-4-5": "Sonnet 4.5", + chat: "Chat", + completions: "Completions", + premium_interactions: "Premium", + session: "Session", + weekly: "Weekly", + agentic_request: "Agentic", + agentic_request_freetrial: "Agentic (Trial)", + }; + return map[name] || name; +} + +// Get bar color based on remaining percentage +function getBarColor(remaining) { + if (remaining > 70) return { bar: "#22c55e", text: "#22c55e", bg: "rgba(34,197,94,0.12)" }; + if (remaining >= 30) return { bar: "#eab308", text: "#eab308", bg: "rgba(234,179,8,0.12)" }; + return { bar: "#ef4444", text: "#ef4444", bg: "rgba(239,68,68,0.12)" }; +} + +// Format countdown +function formatCountdown(resetAt) { + if (!resetAt) return null; + try { + const diff = new Date(resetAt) - new Date(); + if (diff <= 0) return null; + const h = Math.floor(diff / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + if (h >= 24) { + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; + } + return `${h}h ${m}m`; + } catch { + return null; + } +} + +export default function ProviderLimits() { + const [connections, setConnections] = useState([]); + const [quotaData, setQuotaData] = useState({}); + const [loading, setLoading] = useState({}); + const [errors, setErrors] = useState({}); + const [autoRefresh, setAutoRefresh] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const [refreshingAll, setRefreshingAll] = useState(false); + const [countdown, setCountdown] = useState(120); + const [initialLoading, setInitialLoading] = useState(true); + const [tierFilter, setTierFilter] = useState("all"); + + const intervalRef = useRef(null); + const countdownRef = useRef(null); + const lastFetchTimeRef = useRef({}); + + const fetchConnections = useCallback(async () => { + try { + const response = await fetch("/api/providers/client"); + if (!response.ok) throw new Error("Failed"); + const data = await response.json(); + const list = data.connections || []; + setConnections(list); + return list; + } catch { + setConnections([]); + return []; + } + }, []); + + const fetchQuota = useCallback(async (connectionId, provider) => { + // Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago + const now = Date.now(); + const lastFetch = lastFetchTimeRef.current[connectionId] || 0; + if (now - lastFetch < MIN_FETCH_INTERVAL_MS) { + return; // Skip, data is still fresh + } + lastFetchTimeRef.current[connectionId] = now; + + setLoading((prev) => ({ ...prev, [connectionId]: true })); + setErrors((prev) => ({ ...prev, [connectionId]: null })); + try { + const response = await fetch(`/api/usage/${connectionId}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMsg = errorData.error || response.statusText; + if (response.status === 404) return; + if (response.status === 401) { + setQuotaData((prev) => ({ + ...prev, + [connectionId]: { quotas: [], message: errorMsg }, + })); + return; + } + throw new Error(`HTTP ${response.status}: ${errorMsg}`); + } + const data = await response.json(); + const parsedQuotas = parseQuotaData(provider, data); + setQuotaData((prev) => ({ + ...prev, + [connectionId]: { + quotas: parsedQuotas, + plan: data.plan || null, + message: data.message || null, + raw: data, + }, + })); + } catch (error) { + setErrors((prev) => ({ + ...prev, + [connectionId]: error.message || "Failed to fetch quota", + })); + } finally { + setLoading((prev) => ({ ...prev, [connectionId]: false })); + } + }, []); + + const refreshProvider = useCallback( + async (connectionId, provider) => { + await fetchQuota(connectionId, provider); + setLastUpdated(new Date()); + }, + [fetchQuota] + ); + + const refreshAll = useCallback(async () => { + if (refreshingAll) return; + setRefreshingAll(true); + setCountdown(120); + try { + const conns = await fetchConnections(); + const oauthConnections = conns.filter( + (conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" + ); + await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))); + setLastUpdated(new Date()); + } catch (error) { + console.error("Error refreshing all:", error); + } finally { + setRefreshingAll(false); + } + }, [refreshingAll, fetchConnections, fetchQuota]); + + useEffect(() => { + const init = async () => { + setInitialLoading(true); + await refreshAll(); + setInitialLoading(false); + }; + init(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!autoRefresh) { + if (intervalRef.current) clearInterval(intervalRef.current); + if (countdownRef.current) clearInterval(countdownRef.current); + return; + } + intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS); + countdownRef.current = setInterval(() => { + setCountdown((prev) => (prev <= 1 ? 120 : prev - 1)); + }, 1000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + if (countdownRef.current) clearInterval(countdownRef.current); + }; + }, [autoRefresh, refreshAll]); + + useEffect(() => { + const handler = () => { + if (document.hidden) { + if (intervalRef.current) clearInterval(intervalRef.current); + if (countdownRef.current) clearInterval(countdownRef.current); + } else if (autoRefresh) { + intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS); + countdownRef.current = setInterval(() => { + setCountdown((prev) => (prev <= 1 ? 120 : prev - 1)); + }, 1000); + } + }; + document.addEventListener("visibilitychange", handler); + return () => document.removeEventListener("visibilitychange", handler); + }, [autoRefresh, refreshAll]); + + const filteredConnections = useMemo( + () => + connections.filter( + (conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" + ), + [connections] + ); + + const sortedConnections = useMemo(() => { + const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5 }; + return [...filteredConnections].sort( + (a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9) + ); + }, [filteredConnections]); + + const tierByConnection = useMemo(() => { + const out = {}; + for (const conn of sortedConnections) { + out[conn.id] = normalizePlanTier(quotaData[conn.id]?.plan); + } + return out; + }, [sortedConnections, quotaData]); + + const tierCounts = useMemo(() => { + const counts = { + all: sortedConnections.length, + enterprise: 0, + business: 0, + ultra: 0, + pro: 0, + free: 0, + unknown: 0, + }; + for (const conn of sortedConnections) { + const tierKey = tierByConnection[conn.id]?.key || "unknown"; + counts[tierKey] = (counts[tierKey] || 0) + 1; + } + return counts; + }, [sortedConnections, tierByConnection]); + + const visibleConnections = useMemo(() => { + if (tierFilter === "all") return sortedConnections; + return sortedConnections.filter( + (conn) => (tierByConnection[conn.id]?.key || "unknown") === tierFilter + ); + }, [sortedConnections, tierByConnection, tierFilter]); + + if (initialLoading) { + return ( +
+ + +
+ ); + } + + if (sortedConnections.length === 0) { + return ( + +
+ cloud_off +

No Providers Connected

+

+ Connect to providers with OAuth to track your API quota limits and usage. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Provider Limits

+ + {visibleConnections.length} account{visibleConnections.length !== 1 ? "s" : ""} + {visibleConnections.length !== sortedConnections.length + ? ` (filtered from ${sortedConnections.length})` + : ""} + +
+ +
+ + + +
+
+ + {/* Tier Filters */} +
+ {TIER_FILTERS.map((tier) => { + if (tier.key !== "all" && !tierCounts[tier.key]) return null; + const active = tierFilter === tier.key; + return ( + + ); + })} +
+ + {/* Account rows */} +
+ {/* Table header */} +
+
Account
+
Model Quotas
+
Last Used
+
Actions
+
+ + {visibleConnections.map((conn, idx) => { + const quota = quotaData[conn.id]; + const isLoading = loading[conn.id]; + const error = errors[conn.id]; + const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" }; + const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null); + + return ( +
+ {/* Account Info */} +
+
+ {conn.provider} +
+
+
+ {conn.name || config.label} +
+
+ + + {tierMeta.label} + + + {config.label} +
+
+
+ + {/* Quota Bars */} +
+ {isLoading ? ( +
+ + progress_activity + + Loading... +
+ ) : error ? ( +
+ error + + {error} + +
+ ) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? ( +
{quota.message}
+ ) : quota?.quotas?.length > 0 ? ( + quota.quotas.map((q, i) => { + const remaining = + q.remainingPercentage !== undefined + ? Math.round(q.remainingPercentage) + : calculatePercentage(q.used, q.total); + const colors = getBarColor(remaining); + const cd = formatCountdown(q.resetAt); + const shortName = getShortModelName(q.name); + + return ( +
+ {/* Model label */} + + {shortName} + + + {/* Countdown */} + {cd && ( + + ⏱ {cd} + + )} + + {/* Progress bar */} +
+
+
+ + {/* Percentage */} + + {remaining}% + +
+ ); + }) + ) : ( +
No quota data
+ )} +
+ + {/* Last Used */} +
+ {lastUpdated ? ( + + {lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + ) : ( + "-" + )} +
+ + {/* Actions */} +
+ +
+
+ ); + })} + + {visibleConnections.length === 0 && ( +
+ No accounts found for tier filter{" "} + {TIER_FILTERS.find((t) => t.key === tierFilter)?.label || tierFilter}. +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js new file mode 100644 index 0000000000..dfbfa249c3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -0,0 +1,253 @@ +import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.js"; + +/** + * Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit) + * @param {string|Date} date - ISO date string or Date object + * @returns {string} Formatted countdown (e.g., "2d 5h 30m", "4h 40m", "15m") or "-" + */ +export function formatResetTime(date) { + if (!date) return "-"; + + try { + const resetDate = typeof date === "string" ? new Date(date) : date; + const now = new Date(); + const diffMs = resetDate - now; + + if (diffMs <= 0) return "-"; + + const totalMinutes = Math.ceil(diffMs / (1000 * 60)); + + // < 60 minutes: show only minutes + if (totalMinutes < 60) { + return `${totalMinutes}m`; + } + + const totalHours = Math.floor(totalMinutes / 60); + const remainingMinutes = totalMinutes % 60; + + // < 24 hours: show hours and minutes + if (totalHours < 24) { + return `${totalHours}h ${remainingMinutes}m`; + } + + // >= 24 hours: show days, hours, and minutes + const days = Math.floor(totalHours / 24); + const remainingHours = totalHours % 24; + return `${days}d ${remainingHours}h ${remainingMinutes}m`; + } catch (error) { + return "-"; + } +} + +/** + * Get Tailwind color class based on percentage + * @param {number} percentage - Remaining percentage (0-100) + * @returns {string} Color name: "green" | "yellow" | "red" + */ +export function getStatusColor(percentage) { + if (percentage > 70) return "green"; + if (percentage >= 30) return "yellow"; + return "red"; // 0-29% including 0% (out of quota) - show red +} + +/** + * Get status emoji based on percentage + * @param {number} percentage - Remaining percentage (0-100) + * @returns {string} Emoji: "🟢" | "🟡" | "🔴" + */ +export function getStatusEmoji(percentage) { + if (percentage > 70) return "🟢"; + if (percentage >= 30) return "🟡"; + return "🔴"; // 0-29% including 0% (out of quota) - show red +} + +/** + * Calculate remaining percentage + * @param {number} used - Used amount + * @param {number} total - Total amount + * @returns {number} Remaining percentage (0-100) + */ +export function calculatePercentage(used, total) { + if (!total || total === 0) return 0; + if (!used || used < 0) return 100; + if (used >= total) return 0; + + return Math.round(((total - used) / total) * 100); +} + +/** + * Parse provider-specific quota structures into normalized array + * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) + * @param {Object} data - Raw quota data from provider + * @returns {Array} Normalized quota objects with { name, used, total, resetAt } + */ +export function parseQuotaData(provider, data) { + if (!data || typeof data !== "object") return []; + + const normalizedQuotas = []; + + try { + switch (provider.toLowerCase()) { + case "github": + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "antigravity": + if (data.quotas) { + Object.entries(data.quotas).forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey: modelKey, // Keep modelKey for sorting + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + + case "codex": + if (data.quotas) { + Object.entries(data.quotas).forEach(([quotaType, quota]) => { + normalizedQuotas.push({ + name: quotaType, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "kiro": + if (data.quotas) { + Object.entries(data.quotas).forEach(([quotaType, quota]) => { + normalizedQuotas.push({ + name: quotaType, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "claude": + if (data.message) { + // Handle error message case + normalizedQuotas.push({ + name: "error", + used: 0, + total: 0, + resetAt: null, + message: data.message, + }); + } else if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + default: + // Generic fallback for unknown providers + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + } + } catch (error) { + console.error(`Error parsing quota data for ${provider}:`, error); + return []; + } + + // Sort quotas according to PROVIDER_MODELS order + const modelOrder = getModelsByProviderId(provider); + if (modelOrder.length > 0) { + const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); + + normalizedQuotas.sort((a, b) => { + // Use modelKey for antigravity, otherwise use name + const keyA = a.modelKey || a.name; + const keyB = b.modelKey || b.name; + const orderA = orderMap.get(keyA) ?? 999; + const orderB = orderMap.get(keyB) ?? 999; + return orderA - orderB; + }); + } + + return normalizedQuotas; +} + +/** + * Normalize provider-specific plan labels into a shared tier taxonomy. + * Supported tiers: enterprise, business, ultra, pro, free, unknown. + */ +export function normalizePlanTier(plan) { + const raw = typeof plan === "string" ? plan.trim() : ""; + if (!raw) { + return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw: null }; + } + + const upper = raw.toUpperCase(); + + if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) { + return { key: "enterprise", label: "Enterprise", variant: "info", rank: 6, raw }; + } + + if (upper.includes("BUSINESS") || upper.includes("TEAM") || upper.includes("STANDARD")) { + return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; + } + + if (upper.includes("ULTRA")) { + return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; + } + + if ( + upper.includes("PRO") || + upper.includes("PLUS") || + upper.includes("PREMIUM") || + upper.includes("PAID") + ) { + return { key: "pro", label: "Pro", variant: "primary", rank: 3, raw }; + } + + if ( + upper.includes("FREE") || + upper.includes("INDIVIDUAL") || + upper.includes("BASIC") || + upper.includes("TRIAL") || + upper.includes("LEGACY") + ) { + return { key: "free", label: "Free", variant: "default", rank: 1, raw }; + } + + const titleCased = raw + .toLowerCase() + .split(/[\s_-]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + + return { key: "unknown", label: titleCased || "Unknown", variant: "default", rank: 0, raw }; +} diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js new file mode 100644 index 0000000000..2a9c7c5c6b --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -0,0 +1,44 @@ +"use client"; + +import { useState, Suspense } from "react"; +import { + UsageAnalytics, + RequestLoggerV2, + ProxyLogger, + CardSkeleton, + SegmentedControl, +} from "@/shared/components"; +import ProviderLimits from "./components/ProviderLimits"; + +export default function UsagePage() { + const [activeTab, setActiveTab] = useState("overview"); + + return ( +
+ + + {/* Content */} + {activeTab === "overview" && ( + }> + + + )} + {activeTab === "logs" && } + {activeTab === "proxy-logs" && } + {activeTab === "limits" && ( + }> + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/layout.js b/src/app/(dashboard)/layout.js new file mode 100644 index 0000000000..e640e17628 --- /dev/null +++ b/src/app/(dashboard)/layout.js @@ -0,0 +1,5 @@ +import { DashboardLayout } from "@/shared/components"; + +export default function DashboardRootLayout({ children }) { + return {children}; +} diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js new file mode 100644 index 0000000000..dd2b2616d9 --- /dev/null +++ b/src/app/api/auth/login/route.js @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; +import bcrypt from "bcryptjs"; +import { SignJWT } from "jose"; +import { cookies } from "next/headers"; +import { loginSchema, validateBody } from "@/shared/validation/schemas"; + +const SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "omniroute-default-secret-change-me" +); + +export async function POST(request) { + try { + const rawBody = await request.json(); + + // Zod validation + const validation = validateBody(loginSchema, rawBody); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { password } = validation.data; + const settings = await getSettings(); + + // Default password is '123456' if not set + const storedHash = settings.password; + + let isValid = false; + if (storedHash) { + isValid = await bcrypt.compare(password, storedHash); + } else { + // Use env var or default + const initialPassword = process.env.INITIAL_PASSWORD || "123456"; + isValid = password === initialPassword; + } + + if (isValid) { + const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; + const forwardedProtoHeader = request.headers.get("x-forwarded-proto") || ""; + const forwardedProto = forwardedProtoHeader.split(",")[0].trim().toLowerCase(); + const isHttpsRequest = forwardedProto === "https" || request.nextUrl?.protocol === "https:"; + const useSecureCookie = forceSecureCookie || isHttpsRequest; + + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("24h") + .sign(SECRET); + + const cookieStore = await cookies(); + cookieStore.set("auth_token", token, { + httpOnly: true, + secure: useSecureCookie, + sameSite: "lax", + path: "/", + }); + + return NextResponse.json({ success: true }); + } + + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.js b/src/app/api/auth/logout/route.js new file mode 100644 index 0000000000..b09c38d55e --- /dev/null +++ b/src/app/api/auth/logout/route.js @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete("auth_token"); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.js b/src/app/api/cli-tools/antigravity-mitm/alias/route.js new file mode 100644 index 0000000000..568701d52d --- /dev/null +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.js @@ -0,0 +1,41 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { getMitmAlias, setMitmAliasAll } from "@/models"; + +// GET - Get MITM aliases for a tool +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const toolName = searchParams.get("tool"); + const aliases = await getMitmAlias(toolName || undefined); + return NextResponse.json({ aliases }); + } catch (error) { + console.log("Error fetching MITM aliases:", error.message); + return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); + } +} + +// PUT - Save MITM aliases for a specific tool +export async function PUT(request) { + try { + const { tool, mappings } = await request.json(); + + if (!tool || !mappings || typeof mappings !== "object") { + return NextResponse.json({ error: "tool and mappings required" }, { status: 400 }); + } + + const filtered = {}; + for (const [alias, model] of Object.entries(mappings)) { + if (model && model.trim()) { + filtered[alias] = model.trim(); + } + } + + await setMitmAliasAll(tool, filtered); + return NextResponse.json({ success: true, aliases: filtered }); + } catch (error) { + console.log("Error saving MITM aliases:", error.message); + return NextResponse.json({ error: "Failed to save aliases" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js new file mode 100644 index 0000000000..5852a3084c --- /dev/null +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -0,0 +1,82 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { + getMitmStatus, + startMitm, + stopMitm, + getCachedPassword, + setCachedPassword, +} from "@/mitm/manager"; + +// GET - Check MITM status +export async function GET() { + try { + const status = await getMitmStatus(); + return NextResponse.json({ + running: status.running, + pid: status.pid || null, + dnsConfigured: status.dnsConfigured || false, + certExists: status.certExists || false, + hasCachedPassword: !!getCachedPassword(), + }); + } catch (error) { + console.log("Error getting MITM status:", error.message); + return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 }); + } +} + +// POST - Start MITM proxy +export async function POST(request) { + try { + const { apiKey, sudoPassword } = await request.json(); + const isWin = process.platform === "win32"; + const pwd = sudoPassword || getCachedPassword() || ""; + + if (!apiKey || (!isWin && !pwd)) { + return NextResponse.json( + { error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, + { status: 400 } + ); + } + + const result = await startMitm(apiKey, pwd); + if (!isWin) setCachedPassword(pwd); + + return NextResponse.json({ + success: true, + running: result.running, + pid: result.pid, + }); + } catch (error) { + console.log("Error starting MITM:", error.message); + return NextResponse.json( + { error: error.message || "Failed to start MITM proxy" }, + { status: 500 } + ); + } +} + +// DELETE - Stop MITM proxy +export async function DELETE(request) { + try { + const { sudoPassword } = await request.json(); + const isWin = process.platform === "win32"; + const pwd = sudoPassword || getCachedPassword() || ""; + + if (!isWin && !pwd) { + return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); + } + + await stopMitm(pwd); + if (!isWin && sudoPassword) setCachedPassword(sudoPassword); + + return NextResponse.json({ success: true, running: false }); + } catch (error) { + console.log("Error stopping MITM:", error.message); + return NextResponse.json( + { error: error.message || "Failed to stop MITM proxy" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cli-tools/backups/route.js b/src/app/api/cli-tools/backups/route.js new file mode 100644 index 0000000000..fab98d69e8 --- /dev/null +++ b/src/app/api/cli-tools/backups/route.js @@ -0,0 +1,92 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { listBackups, restoreBackup, deleteBackup } from "@/shared/services/backupService"; +import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; + +const VALID_TOOLS = ["claude", "codex", "droid", "openclaw"]; + +// GET /api/cli-tools/backups?tool=claude — list backups +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const tool = searchParams.get("tool"); + + if (tool && !VALID_TOOLS.includes(tool)) { + return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 }); + } + + if (tool) { + const backups = await listBackups(tool); + return NextResponse.json({ tool, backups }); + } + + // List all tools + const result = {}; + for (const t of VALID_TOOLS) { + result[t] = await listBackups(t); + } + return NextResponse.json({ backups: result }); + } catch (error) { + console.log("Error listing backups:", error.message); + return NextResponse.json({ error: "Failed to list backups" }, { status: 500 }); + } +} + +// POST /api/cli-tools/backups { tool, backupId } — restore a backup +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { tool, backupId } = await request.json(); + + if (!tool || !backupId) { + return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 }); + } + + if (!VALID_TOOLS.includes(tool)) { + return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 }); + } + + const result = await restoreBackup(tool, backupId); + return NextResponse.json({ + success: true, + message: `Backup restored for ${tool}`, + ...result, + }); + } catch (error) { + console.log("Error restoring backup:", error.message); + return NextResponse.json( + { error: error.message || "Failed to restore backup" }, + { status: 500 } + ); + } +} + +// DELETE /api/cli-tools/backups { tool, backupId } — delete a backup +export async function DELETE(request) { + try { + const { tool, backupId } = await request.json(); + + if (!tool || !backupId) { + return NextResponse.json({ error: "tool and backupId are required" }, { status: 400 }); + } + + if (!VALID_TOOLS.includes(tool)) { + return NextResponse.json({ error: `Invalid tool: ${tool}` }, { status: 400 }); + } + + const result = await deleteBackup(tool, backupId); + return NextResponse.json({ + success: true, + message: `Backup deleted for ${tool}`, + ...result, + }); + } catch (error) { + console.log("Error deleting backup:", error.message); + return NextResponse.json({ error: "Failed to delete backup" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js new file mode 100644 index 0000000000..c0d8ba8240 --- /dev/null +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -0,0 +1,195 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; + +// Get claude settings path based on OS +const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude"); + +// Read current settings +const readSettings = async () => { + try { + const settingsPath = getClaudeSettingsPath(); + const content = await fs.readFile(settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") { + return null; + } + throw error; + } +}; + +// GET - Check claude CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("claude"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: null, + message: + runtime.installed && !runtime.runnable + ? "Claude CLI is installed but not runnable" + : "Claude CLI is not installed", + }); + } + + const settings = await readSettings(); + const hasOmniRoute = !!settings?.env?.ANTHROPIC_BASE_URL; + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: settings, + hasOmniRoute: hasOmniRoute, + settingsPath: getClaudeSettingsPath(), + }); + } catch (error) { + console.log("Error checking claude settings:", error); + return NextResponse.json({ error: "Failed to check claude settings" }, { status: 500 }); + } +} + +// POST - Backup old fields and write new settings +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { env } = await request.json(); + + if (!env || typeof env !== "object") { + return NextResponse.json({ error: "Invalid env object" }, { status: 400 }); + } + + const settingsPath = getClaudeSettingsPath(); + const claudeDir = path.dirname(settingsPath); + + // Ensure .claude directory exists + await fs.mkdir(claudeDir, { recursive: true }); + + // Backup current settings before modifying + await createBackup("claude", settingsPath); + + // Read current settings + let currentSettings = {}; + try { + const content = await fs.readFile(settingsPath, "utf-8"); + currentSettings = JSON.parse(content); + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + + // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix + if (env.ANTHROPIC_BASE_URL) { + env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") + ? env.ANTHROPIC_BASE_URL + : `${env.ANTHROPIC_BASE_URL}/v1`; + } + + // Merge new env with existing settings + const newSettings = { + ...currentSettings, + env: { + ...(currentSettings.env || {}), + ...env, + }, + }; + + // Write new settings + await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Settings updated successfully", + }); + } catch (error) { + console.log("Error updating claude settings:", error); + return NextResponse.json({ error: "Failed to update claude settings" }, { status: 500 }); + } +} + +// Fields to remove when resetting +const RESET_ENV_KEYS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "API_TIMEOUT_MS", +]; + +// DELETE - Reset settings (remove env fields) +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const settingsPath = getClaudeSettingsPath(); + + // Read current settings + let currentSettings = {}; + try { + const content = await fs.readFile(settingsPath, "utf-8"); + currentSettings = JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No settings file to reset", + }); + } + throw error; + } + + // Backup current settings before resetting + await createBackup("claude", settingsPath); + + // Remove specified env fields + if (currentSettings.env) { + RESET_ENV_KEYS.forEach((key) => { + delete currentSettings.env[key]; + }); + + // Clean up empty env object + if (Object.keys(currentSettings.env).length === 0) { + delete currentSettings.env; + } + } + + // Write updated settings + await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Settings reset successfully", + }); + } catch (error) { + console.log("Error resetting claude settings:", error); + return NextResponse.json({ error: "Failed to reset claude settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/cline-settings/route.js b/src/app/api/cli-tools/cline-settings/route.js new file mode 100644 index 0000000000..650737ea0d --- /dev/null +++ b/src/app/api/cli-tools/cline-settings/route.js @@ -0,0 +1,221 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; + +const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data"); +const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json"); +const SECRETS_PATH = path.join(CLINE_DATA_DIR, "secrets.json"); + +// Read globalState.json +const readGlobalState = async () => { + try { + const content = await fs.readFile(GLOBAL_STATE_PATH, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Read secrets.json +const readSecrets = async () => { + try { + const content = await fs.readFile(SECRETS_PATH, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw error; + } +}; + +// Check if OmniRoute is configured as OpenAI-compatible provider +const hasOmniRouteConfig = (globalState) => { + if (!globalState) return false; + const isOpenAi = + globalState.actModeApiProvider === "openai" || globalState.planModeApiProvider === "openai"; + const baseUrl = globalState.openAiBaseUrl || ""; + return ( + isOpenAi && + (baseUrl.includes("localhost") || + baseUrl.includes("127.0.0.1") || + baseUrl.includes("omniroute")) + ); +}; + +// GET - Check cline CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("cline"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: null, + message: + runtime.installed && !runtime.runnable + ? "Cline CLI is installed but not runnable" + : "Cline CLI is not installed", + }); + } + + const globalState = await readGlobalState(); + const secrets = await readSecrets(); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: { + actModeApiProvider: globalState?.actModeApiProvider, + planModeApiProvider: globalState?.planModeApiProvider, + openAiBaseUrl: globalState?.openAiBaseUrl, + openAiModelId: globalState?.openAiModelId, + planModeOpenAiModelId: globalState?.planModeOpenAiModelId, + }, + hasOmniRoute: hasOmniRouteConfig(globalState), + globalStatePath: GLOBAL_STATE_PATH, + secretsPath: SECRETS_PATH, + }); + } catch (error) { + console.log("Error checking cline settings:", error); + return NextResponse.json({ error: "Failed to check cline settings" }, { status: 500 }); + } +} + +// POST - Configure Cline to use OmniRoute as OpenAI-compatible provider +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + // Ensure directory exists + await fs.mkdir(CLINE_DATA_DIR, { recursive: true }); + + // Backup current files before modifying + await createBackup("cline", GLOBAL_STATE_PATH); + await createBackup("cline", SECRETS_PATH); + + // Read existing globalState or create new + let globalState = {}; + try { + const existing = await fs.readFile(GLOBAL_STATE_PATH, "utf-8"); + globalState = JSON.parse(existing); + } catch { + /* No existing config */ + } + + // Normalize baseUrl - Cline expects the base without /v1 + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; + + // Set OpenAI-compatible provider for both act and plan modes + globalState.actModeApiProvider = "openai"; + globalState.planModeApiProvider = "openai"; + globalState.openAiBaseUrl = normalizedBaseUrl; + globalState.openAiModelId = model; + globalState.planModeOpenAiModelId = model; + + // Write globalState + await fs.writeFile(GLOBAL_STATE_PATH, JSON.stringify(globalState, null, 2)); + + // Write API key to secrets + let secrets = {}; + try { + const existing = await fs.readFile(SECRETS_PATH, "utf-8"); + secrets = JSON.parse(existing); + } catch { + /* No existing secrets */ + } + + secrets.openAiApiKey = apiKey || "sk_omniroute"; + + await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2)); + + return NextResponse.json({ + success: true, + message: "Cline settings applied successfully!", + globalStatePath: GLOBAL_STATE_PATH, + }); + } catch (error) { + console.log("Error updating cline settings:", error); + return NextResponse.json({ error: "Failed to update cline settings" }, { status: 500 }); + } +} + +// DELETE - Remove OmniRoute OpenAI-compatible provider config +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + // Backup before reset + await createBackup("cline", GLOBAL_STATE_PATH); + await createBackup("cline", SECRETS_PATH); + + // Read existing state + let globalState = {}; + try { + const existing = await fs.readFile(GLOBAL_STATE_PATH, "utf-8"); + globalState = JSON.parse(existing); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No settings file to reset" }); + } + throw error; + } + + // Only reset if currently set to openai mode with our config + if (globalState.actModeApiProvider === "openai") { + delete globalState.openAiBaseUrl; + delete globalState.openAiModelId; + delete globalState.planModeOpenAiModelId; + // Reset provider to default (cline) + globalState.actModeApiProvider = "cline"; + globalState.planModeApiProvider = "cline"; + } + + await fs.writeFile(GLOBAL_STATE_PATH, JSON.stringify(globalState, null, 2)); + + // Remove API key from secrets + let secrets = {}; + try { + const existing = await fs.readFile(SECRETS_PATH, "utf-8"); + secrets = JSON.parse(existing); + } catch { + /* ignore */ + } + + delete secrets.openAiApiKey; + await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2)); + + return NextResponse.json({ + success: true, + message: "OmniRoute settings removed from Cline", + }); + } catch (error) { + console.log("Error resetting cline settings:", error); + return NextResponse.json({ error: "Failed to reset cline settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/codex-profiles/route.js b/src/app/api/cli-tools/codex-profiles/route.js new file mode 100644 index 0000000000..17844a83fb --- /dev/null +++ b/src/app/api/cli-tools/codex-profiles/route.js @@ -0,0 +1,234 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime"; + +const PROFILES_DIR = path.join(os.homedir(), ".omniroute", "codex-profiles"); + +/** + * Ensure profiles directory exists + */ +async function ensureProfilesDir() { + await fs.mkdir(PROFILES_DIR, { recursive: true }); + return PROFILES_DIR; +} + +/** + * Extract a label from auth.json content (email or auth_mode) + */ +function extractAuthLabel(authJson) { + try { + const data = JSON.parse(authJson); + // ChatGPT-style auth + if (data.tokens?.id_token) { + const payload = data.tokens.id_token.split(".")[1]; + const decoded = JSON.parse(Buffer.from(payload, "base64").toString()); + if (decoded.email) return decoded.email; + } + if (data.auth_mode) return data.auth_mode; + if (data.OPENAI_API_KEY) return `API Key: ${data.OPENAI_API_KEY.slice(0, 8)}...`; + return "unknown"; + } catch { + return "unknown"; + } +} + +// GET - List all saved profiles +export async function GET() { + try { + await ensureProfilesDir(); + + let entries; + try { + entries = await fs.readdir(PROFILES_DIR); + } catch { + return NextResponse.json({ profiles: [] }); + } + + const profileFiles = entries.filter((e) => e.endsWith(".json")); + const profiles = []; + + for (const file of profileFiles) { + try { + const raw = await fs.readFile(path.join(PROFILES_DIR, file), "utf-8"); + const profile = JSON.parse(raw); + profiles.push({ + id: file.replace(".json", ""), + name: profile.name, + authLabel: profile.authLabel || "unknown", + createdAt: profile.createdAt, + hasConfig: !!profile.configToml, + hasAuth: !!profile.authJson, + }); + } catch { + // Skip corrupt files + } + } + + // Sort by name + profiles.sort((a, b) => a.name.localeCompare(b.name)); + return NextResponse.json({ profiles }); + } catch (error) { + console.log("Error listing codex profiles:", error.message); + return NextResponse.json({ error: "Failed to list profiles" }, { status: 500 }); + } +} + +// POST - Save current config as a named profile +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { name } = await request.json(); + + if (!name || typeof name !== "string" || !name.trim()) { + return NextResponse.json({ error: "Profile name is required" }, { status: 400 }); + } + + const paths = getCliConfigPaths("codex"); + if (!paths) { + return NextResponse.json({ error: "Codex config paths not found" }, { status: 500 }); + } + + // Read current files + let configToml = null; + let authJson = null; + + try { + configToml = await fs.readFile(paths.config, "utf-8"); + } catch { + // No config file + } + + try { + authJson = await fs.readFile(paths.auth, "utf-8"); + } catch { + // No auth file + } + + if (!configToml && !authJson) { + return NextResponse.json( + { error: "No Codex configuration files found to save" }, + { status: 400 } + ); + } + + const profileId = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + const profile = { + name: name.trim(), + createdAt: new Date().toISOString(), + authLabel: authJson ? extractAuthLabel(authJson) : "no-auth", + configToml, + authJson, + }; + + await ensureProfilesDir(); + const profilePath = path.join(PROFILES_DIR, `${profileId}.json`); + await fs.writeFile(profilePath, JSON.stringify(profile, null, 2)); + + return NextResponse.json({ + success: true, + message: `Profile "${name}" saved successfully`, + profileId, + }); + } catch (error) { + console.log("Error saving codex profile:", error.message); + return NextResponse.json({ error: "Failed to save profile" }, { status: 500 }); + } +} + +// PUT - Activate a saved profile (restore its config + auth) +export async function PUT(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { profileId } = await request.json(); + + if (!profileId) { + return NextResponse.json({ error: "profileId is required" }, { status: 400 }); + } + + const profilePath = path.join(PROFILES_DIR, `${profileId}.json`); + let profile; + try { + const raw = await fs.readFile(profilePath, "utf-8"); + profile = JSON.parse(raw); + } catch { + return NextResponse.json({ error: `Profile "${profileId}" not found` }, { status: 404 }); + } + + const paths = getCliConfigPaths("codex"); + if (!paths) { + return NextResponse.json({ error: "Codex config paths not found" }, { status: 500 }); + } + + // Create backup of current config before switching + const { createMultiBackup } = await import("@/shared/services/backupService"); + await createMultiBackup("codex", [paths.config, paths.auth]); + + // Ensure codex dir exists + await fs.mkdir(path.dirname(paths.config), { recursive: true }); + + // Restore files + if (profile.configToml) { + await fs.writeFile(paths.config, profile.configToml); + } + if (profile.authJson) { + await fs.writeFile(paths.auth, profile.authJson); + } + + return NextResponse.json({ + success: true, + message: `Profile "${profile.name}" activated`, + profileId, + restoredConfig: !!profile.configToml, + restoredAuth: !!profile.authJson, + }); + } catch (error) { + console.log("Error activating codex profile:", error.message); + return NextResponse.json({ error: "Failed to activate profile" }, { status: 500 }); + } +} + +// DELETE - Remove a saved profile +export async function DELETE(request) { + try { + const { profileId } = await request.json(); + + if (!profileId) { + return NextResponse.json({ error: "profileId is required" }, { status: 400 }); + } + + const profilePath = path.join(PROFILES_DIR, `${profileId}.json`); + try { + await fs.unlink(profilePath); + } catch (err) { + if (err.code === "ENOENT") { + return NextResponse.json({ error: `Profile "${profileId}" not found` }, { status: 404 }); + } + throw err; + } + + return NextResponse.json({ + success: true, + message: `Profile "${profileId}" deleted`, + }); + } catch (error) { + console.log("Error deleting codex profile:", error.message); + return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js new file mode 100644 index 0000000000..7421d289e1 --- /dev/null +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -0,0 +1,281 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { + ensureCliConfigWriteAllowed, + getCliConfigPaths, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createMultiBackup } from "@/shared/services/backupService"; + +const getCodexConfigPath = () => getCliConfigPaths("codex").config; +const getCodexAuthPath = () => getCliConfigPaths("codex").auth; +const getCodexDir = () => path.dirname(getCodexConfigPath()); + +// Parse TOML config to object (simple parser for codex config) +const parseToml = (content) => { + const result = { _root: {}, _sections: {} }; + let currentSection = "_root"; + + content.split("\n").forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + + // Section header like [model_providers.omniroute] + const sectionMatch = trimmed.match(/^\[(.+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + result._sections[currentSection] = {}; + return; + } + + // Key = value + const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/); + if (kvMatch) { + const key = kvMatch[1].trim(); + let value = kvMatch[2].trim(); + // Remove quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (currentSection === "_root") { + result._root[key] = value; + } else { + result._sections[currentSection][key] = value; + } + } + }); + + return result; +}; + +// Convert parsed object back to TOML string +const toToml = (parsed) => { + let lines = []; + + // Root level keys + Object.entries(parsed._root).forEach(([key, value]) => { + lines.push(`${key} = "${value}"`); + }); + + // Sections + Object.entries(parsed._sections).forEach(([section, values]) => { + lines.push(""); + lines.push(`[${section}]`); + Object.entries(values).forEach(([key, value]) => { + lines.push(`${key} = "${value}"`); + }); + }); + + return lines.join("\n") + "\n"; +}; + +// Read current config.toml +const readConfig = async () => { + try { + const configPath = getCodexConfigPath(); + const content = await fs.readFile(configPath, "utf-8"); + return content; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if config has OmniRoute settings +const hasOmniRouteConfig = (config) => { + if (!config) return false; + return ( + config.includes('model_provider = "omniroute"') || + config.includes("[model_providers.omniroute]") + ); +}; + +// GET - Check codex CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("codex"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config: null, + message: + runtime.installed && !runtime.runnable + ? "Codex CLI is installed but not runnable" + : "Codex CLI is not installed", + }); + } + + const config = await readConfig(); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + config, + hasOmniRoute: hasOmniRouteConfig(config), + configPath: getCodexConfigPath(), + }); + } catch (error) { + console.log("Error checking codex settings:", error); + return NextResponse.json({ error: "Failed to check codex settings" }, { status: 500 }); + } +} + +// POST - Update OmniRoute settings (merge with existing config) +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !apiKey || !model) { + return NextResponse.json( + { error: "baseUrl, apiKey and model are required" }, + { status: 400 } + ); + } + + const codexDir = getCodexDir(); + const configPath = getCodexConfigPath(); + const authPath = getCodexAuthPath(); + + // Ensure directory exists + await fs.mkdir(codexDir, { recursive: true }); + + // Backup current configs before modifying + await createMultiBackup("codex", [configPath, authPath]); + + // Read and parse existing config + let parsed = { _root: {}, _sections: {} }; + try { + const existingConfig = await fs.readFile(configPath, "utf-8"); + parsed = parseToml(existingConfig); + } catch { + /* No existing config */ + } + + // Update only OmniRoute related fields (api_key goes to auth.json, not config.toml) + parsed._root.model = model; + parsed._root.model_provider = "omniroute"; + + // Update or create omniroute provider section (no api_key - Codex reads from auth.json) + // Ensure /v1 suffix is added only once + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + parsed._sections["model_providers.omniroute"] = { + name: "OmniRoute", + base_url: normalizedBaseUrl, + wire_api: "responses", + }; + + // Write merged config + const configContent = toToml(parsed); + await fs.writeFile(configPath, configContent); + + // Update auth.json with OPENAI_API_KEY (Codex reads this first) + let authData = {}; + try { + const existingAuth = await fs.readFile(authPath, "utf-8"); + authData = JSON.parse(existingAuth); + } catch { + /* No existing auth */ + } + + authData.OPENAI_API_KEY = apiKey; + await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); + + return NextResponse.json({ + success: true, + message: "Codex settings applied successfully!", + configPath, + }); + } catch (error) { + console.log("Error updating codex settings:", error); + return NextResponse.json({ error: "Failed to update codex settings" }, { status: 500 }); + } +} + +// DELETE - Remove OmniRoute settings only (keep other settings) +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const configPath = getCodexConfigPath(); + + // Backup current configs before resetting + await createMultiBackup("codex", [configPath, getCodexAuthPath()]); + + // Read and parse existing config + let parsed = { _root: {}, _sections: {} }; + try { + const existingConfig = await fs.readFile(configPath, "utf-8"); + parsed = parseToml(existingConfig); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No config file to reset", + }); + } + throw error; + } + + // Remove OmniRoute related root fields only if they point to omniroute + if (parsed._root.model_provider === "omniroute") { + delete parsed._root.model; + delete parsed._root.model_provider; + } + + // Remove omniroute provider section + delete parsed._sections["model_providers.omniroute"]; + + // Write updated config + const configContent = toToml(parsed); + await fs.writeFile(configPath, configContent); + + // Remove OPENAI_API_KEY from auth.json + const authPath = getCodexAuthPath(); + try { + const existingAuth = await fs.readFile(authPath, "utf-8"); + const authData = JSON.parse(existingAuth); + delete authData.OPENAI_API_KEY; + + // Write back or delete if empty + if (Object.keys(authData).length === 0) { + await fs.unlink(authPath); + } else { + await fs.writeFile(authPath, JSON.stringify(authData, null, 2)); + } + } catch { + /* No auth file */ + } + + return NextResponse.json({ + success: true, + message: "OmniRoute settings removed successfully", + }); + } catch (error) { + console.log("Error resetting codex settings:", error); + return NextResponse.json({ error: "Failed to reset codex settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js new file mode 100644 index 0000000000..f7c220b8d1 --- /dev/null +++ b/src/app/api/cli-tools/droid-settings/route.js @@ -0,0 +1,195 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; + +const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid"); +const getDroidDir = () => path.dirname(getDroidSettingsPath()); + +// Read current settings.json +const readSettings = async () => { + try { + const settingsPath = getDroidSettingsPath(); + const content = await fs.readFile(settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if settings has OmniRoute customModels +const hasOmniRouteConfig = (settings) => { + if (!settings || !settings.customModels) return false; + return settings.customModels.some((m) => m.id === "custom:OmniRoute-0"); +}; + +// GET - Check droid CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("droid"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: null, + message: + runtime.installed && !runtime.runnable + ? "Factory Droid CLI is installed but not runnable" + : "Factory Droid CLI is not installed", + }); + } + + const settings = await readSettings(); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings, + hasOmniRoute: hasOmniRouteConfig(settings), + settingsPath: getDroidSettingsPath(), + }); + } catch (error) { + console.log("Error checking droid settings:", error); + return NextResponse.json({ error: "Failed to check droid settings" }, { status: 500 }); + } +} + +// POST - Update OmniRoute customModels (merge with existing settings) +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const droidDir = getDroidDir(); + const settingsPath = getDroidSettingsPath(); + + // Ensure directory exists + await fs.mkdir(droidDir, { recursive: true }); + + // Backup current settings before modifying + await createBackup("droid", settingsPath); + + // Read existing settings or create new + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch { + /* No existing settings */ + } + + // Ensure customModels array exists + if (!settings.customModels) { + settings.customModels = []; + } + + // Remove existing OmniRoute config if any + settings.customModels = settings.customModels.filter((m) => m.id !== "custom:OmniRoute-0"); + + // Normalize baseUrl to ensure /v1 suffix + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Add new OmniRoute config + const customModel = { + model: model, + id: "custom:OmniRoute-0", + index: 0, + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "your_api_key", + displayName: model, + maxOutputTokens: 131072, + noImageSupport: false, + provider: "openai", + }; + + settings.customModels.unshift(customModel); + + // Write settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Factory Droid settings applied successfully!", + settingsPath, + }); + } catch (error) { + console.log("Error updating droid settings:", error); + return NextResponse.json({ error: "Failed to update droid settings" }, { status: 500 }); + } +} + +// DELETE - Remove OmniRoute customModels only (keep other settings) +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const settingsPath = getDroidSettingsPath(); + + // Backup current settings before resetting + await createBackup("droid", settingsPath); + + // Read existing settings + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No settings file to reset", + }); + } + throw error; + } + + // Remove OmniRoute customModels + if (settings.customModels) { + settings.customModels = settings.customModels.filter((m) => m.id !== "custom:OmniRoute-0"); + + // Remove customModels array if empty + if (settings.customModels.length === 0) { + delete settings.customModels; + } + } + + // Write updated settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "OmniRoute settings removed successfully", + }); + } catch (error) { + console.log("Error resetting droid settings:", error); + return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.js b/src/app/api/cli-tools/guide-settings/[toolId]/route.js new file mode 100644 index 0000000000..e4546d9f77 --- /dev/null +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.js @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +/** + * POST /api/cli-tools/guide-settings/:toolId + * + * Save configuration for guide-based tools that have config files. + * Currently supports: continue + */ +export async function POST(request, { params }) { + const { toolId } = await params; + const { baseUrl, apiKey, model } = await request.json(); + + if (!model) { + return NextResponse.json({ error: "Model is required" }, { status: 400 }); + } + + try { + switch (toolId) { + case "continue": + return await saveContinueConfig({ baseUrl, apiKey, model }); + default: + return NextResponse.json( + { error: `Direct config save not supported for: ${toolId}` }, + { status: 400 } + ); + } + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Save Continue config to ~/.continue/config.json + * Merges with existing config if present. + */ +async function saveContinueConfig({ baseUrl, apiKey, model }) { + const configPath = path.join(os.homedir(), ".continue", "config.json"); + const configDir = path.dirname(configPath); + + // Ensure dir exists + await fs.mkdir(configDir, { recursive: true }); + + // Read existing config if any + let existingConfig = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + existingConfig = JSON.parse(raw); + } catch { + // No existing config or invalid JSON — start fresh + } + + // Build the OmniRoute model entry + const routerModel = { + apiBase: baseUrl, + title: model, + model: model, + provider: "openai", + apiKey: apiKey || "sk_omniroute", + }; + + // Merge into existing models array + const models = existingConfig.models || []; + + // Check if OmniRoute entry already exists and update it, or add new + const existingIdx = models.findIndex( + (m) => + m.apiBase && + (m.apiBase.includes("localhost:20128") || + m.apiBase.includes("omniroute") || + m.title === model) + ); + + if (existingIdx >= 0) { + models[existingIdx] = routerModel; + } else { + models.push(routerModel); + } + + existingConfig.models = models; + + // Write back + await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8"); + + return NextResponse.json({ + success: true, + message: `Continue config saved to ${configPath}`, + configPath, + }); +} diff --git a/src/app/api/cli-tools/kilo-settings/route.js b/src/app/api/cli-tools/kilo-settings/route.js new file mode 100644 index 0000000000..359965cfd3 --- /dev/null +++ b/src/app/api/cli-tools/kilo-settings/route.js @@ -0,0 +1,244 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; + +const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo"); +const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json"); +const KILO_CONFIG_DIR = path.join(os.homedir(), ".config", "kilo"); + +// Read auth.json +const readAuth = async () => { + try { + const content = await fs.readFile(AUTH_PATH, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if OmniRoute OpenAI-compatible provider is configured +const hasOmniRouteConfig = (auth) => { + if (!auth) return false; + const routerEntry = auth["openai-compatible"] || auth["omniroute"]; + if (!routerEntry) return false; + const baseUrl = routerEntry.baseUrl || routerEntry.baseURL || ""; + return ( + baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1") || baseUrl.includes("omniroute") + ); +}; + +// GET - Check kilo CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("kilo"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: null, + message: + runtime.installed && !runtime.runnable + ? "Kilo Code CLI is installed but not runnable" + : "Kilo Code CLI is not installed", + }); + } + + const auth = await readAuth(); + + // Read kilo VS Code extension settings if available + let extensionSettings = null; + try { + const vscodeSettingsPath = path.join( + os.homedir(), + ".config", + "Code", + "User", + "settings.json" + ); + const raw = await fs.readFile(vscodeSettingsPath, "utf-8"); + const allSettings = JSON.parse(raw); + // Extract kilo-related settings + extensionSettings = {}; + for (const [key, value] of Object.entries(allSettings)) { + if ( + key.startsWith("kilocode.") || + key.startsWith("kilo-code.") || + key.startsWith("kilo.") + ) { + extensionSettings[key] = value; + } + } + } catch { + /* VS Code settings not available */ + } + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: { + auth: auth ? Object.keys(auth) : [], + extensionSettings, + }, + hasOmniRoute: hasOmniRouteConfig(auth), + authPath: AUTH_PATH, + }); + } catch (error) { + console.log("Error checking kilo settings:", error); + return NextResponse.json({ error: "Failed to check kilo settings" }, { status: 500 }); + } +} + +// POST - Configure Kilo Code to use OmniRoute as OpenAI-compatible provider +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + // Ensure directories exist + await fs.mkdir(KILO_DATA_DIR, { recursive: true }); + + // Backup auth before modifying + await createBackup("kilo", AUTH_PATH); + + // Read existing auth + let auth = {}; + try { + const existing = await fs.readFile(AUTH_PATH, "utf-8"); + auth = JSON.parse(existing); + } catch { + /* No existing auth */ + } + + // Normalize baseUrl + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Add/update OmniRoute as openai-compatible provider + auth["openai-compatible"] = { + type: "api-key", + apiKey: apiKey || "sk_omniroute", + baseUrl: normalizedBaseUrl, + model: model, + }; + + await fs.writeFile(AUTH_PATH, JSON.stringify(auth, null, 2)); + + // Also try to update VS Code extension settings if available + try { + const vscodeSettingsPath = path.join( + os.homedir(), + ".config", + "Code", + "User", + "settings.json" + ); + let vscodeSettings = {}; + try { + const raw = await fs.readFile(vscodeSettingsPath, "utf-8"); + vscodeSettings = JSON.parse(raw); + } catch { + /* no existing settings */ + } + + // Set custom provider config for the extension + vscodeSettings["kilocode.customProvider"] = { + name: "OmniRoute", + baseURL: normalizedBaseUrl, + apiKey: apiKey || "sk_omniroute", + }; + vscodeSettings["kilocode.defaultModel"] = model; + + await fs.writeFile(vscodeSettingsPath, JSON.stringify(vscodeSettings, null, 2)); + } catch { + // VS Code settings not writable — not a problem for CLI + } + + return NextResponse.json({ + success: true, + message: "Kilo Code settings applied successfully!", + authPath: AUTH_PATH, + }); + } catch (error) { + console.log("Error updating kilo settings:", error); + return NextResponse.json({ error: "Failed to update kilo settings" }, { status: 500 }); + } +} + +// DELETE - Remove OmniRoute config from Kilo +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + // Backup before reset + await createBackup("kilo", AUTH_PATH); + + // Read existing auth + let auth = {}; + try { + const existing = await fs.readFile(AUTH_PATH, "utf-8"); + auth = JSON.parse(existing); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No settings file to reset" }); + } + throw error; + } + + // Remove OmniRoute provider + delete auth["openai-compatible"]; + delete auth["omniroute"]; + + await fs.writeFile(AUTH_PATH, JSON.stringify(auth, null, 2)); + + // Also clean up VS Code extension settings + try { + const vscodeSettingsPath = path.join( + os.homedir(), + ".config", + "Code", + "User", + "settings.json" + ); + const raw = await fs.readFile(vscodeSettingsPath, "utf-8"); + const vscodeSettings = JSON.parse(raw); + delete vscodeSettings["kilocode.customProvider"]; + delete vscodeSettings["kilocode.defaultModel"]; + await fs.writeFile(vscodeSettingsPath, JSON.stringify(vscodeSettings, null, 2)); + } catch { + /* ignore */ + } + + return NextResponse.json({ + success: true, + message: "OmniRoute settings removed from Kilo Code", + }); + } catch (error) { + console.log("Error resetting kilo settings:", error); + return NextResponse.json({ error: "Failed to reset kilo settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js new file mode 100644 index 0000000000..2b91502575 --- /dev/null +++ b/src/app/api/cli-tools/openclaw-settings/route.js @@ -0,0 +1,200 @@ +"use server"; + +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { + ensureCliConfigWriteAllowed, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; +import { createBackup } from "@/shared/services/backupService"; + +const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw"); +const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath()); + +// Read current settings.json +const readSettings = async () => { + try { + const settingsPath = getOpenClawSettingsPath(); + const content = await fs.readFile(settingsPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +}; + +// Check if settings has OmniRoute config +const hasOmniRouteConfig = (settings) => { + if (!settings || !settings.models || !settings.models.providers) return false; + return !!settings.models.providers["omniroute"]; +}; + +// GET - Check openclaw CLI and read current settings +export async function GET() { + try { + const runtime = await getCliRuntimeStatus("openclaw"); + + if (!runtime.installed || !runtime.runnable) { + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings: null, + message: + runtime.installed && !runtime.runnable + ? "Open Claw CLI is installed but not runnable" + : "Open Claw CLI is not installed", + }); + } + + const settings = await readSettings(); + + return NextResponse.json({ + installed: runtime.installed, + runnable: runtime.runnable, + command: runtime.command, + commandPath: runtime.commandPath, + runtimeMode: runtime.runtimeMode, + reason: runtime.reason, + settings, + hasOmniRoute: hasOmniRouteConfig(settings), + settingsPath: getOpenClawSettingsPath(), + }); + } catch (error) { + console.log("Error checking openclaw settings:", error); + return NextResponse.json({ error: "Failed to check openclaw settings" }, { status: 500 }); + } +} + +// POST - Update OmniRoute settings (merge with existing settings) +export async function POST(request) { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const { baseUrl, apiKey, model } = await request.json(); + + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const openclawDir = getOpenClawDir(); + const settingsPath = getOpenClawSettingsPath(); + + // Ensure directory exists + await fs.mkdir(openclawDir, { recursive: true }); + + // Backup current settings before modifying + await createBackup("openclaw", settingsPath); + + // Read existing settings or create new + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch { + /* No existing settings */ + } + + // Ensure structure exists + if (!settings.agents) settings.agents = {}; + if (!settings.agents.defaults) settings.agents.defaults = {}; + if (!settings.agents.defaults.model) settings.agents.defaults.model = {}; + if (!settings.models) settings.models = {}; + if (!settings.models.providers) settings.models.providers = {}; + + // Normalize baseUrl to ensure /v1 suffix + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Update agents.defaults.model.primary + settings.agents.defaults.model.primary = `omniroute/${model}`; + + // Update models.providers.omniroute + settings.models.providers["omniroute"] = { + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "your_api_key", + api: "openai-completions", + models: [ + { + id: model, + name: model.split("/").pop() || model, + }, + ], + }; + + // Write settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "Open Claw settings applied successfully!", + settingsPath, + }); + } catch (error) { + console.log("Error updating openclaw settings:", error); + return NextResponse.json({ error: "Failed to update openclaw settings" }, { status: 500 }); + } +} + +// DELETE - Remove OmniRoute settings only (keep other settings) +export async function DELETE() { + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard }, { status: 403 }); + } + + const settingsPath = getOpenClawSettingsPath(); + + // Backup current settings before resetting + await createBackup("openclaw", settingsPath); + + // Read existing settings + let settings = {}; + try { + const existingSettings = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(existingSettings); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ + success: true, + message: "No settings file to reset", + }); + } + throw error; + } + + // Remove OmniRoute from models.providers + if (settings.models && settings.models.providers) { + delete settings.models.providers["omniroute"]; + + // Remove providers object if empty + if (Object.keys(settings.models.providers).length === 0) { + delete settings.models.providers; + } + } + + // Reset agents.defaults.model.primary if it uses omniroute + if (settings.agents?.defaults?.model?.primary?.startsWith("omniroute/")) { + delete settings.agents.defaults.model.primary; + } + + // Write updated settings + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return NextResponse.json({ + success: true, + message: "OmniRoute settings removed successfully", + }); + } catch (error) { + console.log("Error resetting openclaw settings:", error); + return NextResponse.json({ error: "Failed to reset openclaw settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/runtime/[toolId]/route.js b/src/app/api/cli-tools/runtime/[toolId]/route.js new file mode 100644 index 0000000000..27c5eb7d28 --- /dev/null +++ b/src/app/api/cli-tools/runtime/[toolId]/route.js @@ -0,0 +1,39 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { + CLI_TOOL_IDS, + getCliPrimaryConfigPath, + getCliRuntimeStatus, +} from "@/shared/services/cliRuntime"; + +export async function GET(_request, { params }) { + try { + const { toolId } = await params; + const normalizedToolId = String(toolId || "") + .trim() + .toLowerCase(); + + if (!CLI_TOOL_IDS.includes(normalizedToolId)) { + return NextResponse.json({ error: "Unsupported CLI tool" }, { status: 404 }); + } + + const runtime = await getCliRuntimeStatus(normalizedToolId); + return NextResponse.json({ + ...runtime, + toolId: normalizedToolId, + configPath: getCliPrimaryConfigPath(normalizedToolId), + message: + runtime.reason === "not_required" + ? "This integration is guide-based and does not require a local CLI binary" + : runtime.installed && runtime.runnable + ? "CLI detected and runnable" + : runtime.installed + ? "CLI detected but not runnable" + : "CLI not detected", + }); + } catch (error) { + console.log("Error checking CLI runtime:", error); + return NextResponse.json({ error: "Failed to check CLI runtime" }, { status: 500 }); + } +} diff --git a/src/app/api/cloud/auth/route.js b/src/app/api/cloud/auth/route.js new file mode 100644 index 0000000000..709c65b424 --- /dev/null +++ b/src/app/api/cloud/auth/route.js @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { validateApiKey, getProviderConnections, getModelAliases } from "@/models"; + +// Verify API key and return provider credentials +export async function POST(request) { + try { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + + // Validate API key + const isValid = await validateApiKey(apiKey); + if (!isValid) { + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + } + + // Get active provider connections + const connections = await getProviderConnections({ isActive: true }); + + // Map connections + const mappedConnections = connections.map((conn) => ({ + provider: conn.provider, + authType: conn.authType, + apiKey: conn.apiKey || null, + accessToken: conn.accessToken || null, + refreshToken: conn.refreshToken || null, + projectId: conn.projectId || null, + expiresAt: conn.expiresAt, + priority: conn.priority, + globalPriority: conn.globalPriority, + defaultModel: conn.defaultModel, + isActive: conn.isActive, + })); + + // Get model aliases + const modelAliases = await getModelAliases(); + + return NextResponse.json({ + connections: mappedConnections, + modelAliases, + }); + } catch (error) { + console.log("Cloud auth error:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/cloud/credentials/update/route.js b/src/app/api/cloud/credentials/update/route.js new file mode 100644 index 0000000000..90e0bd9a27 --- /dev/null +++ b/src/app/api/cloud/credentials/update/route.js @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models"; + +// Update provider credentials (for cloud token refresh) +export async function PUT(request) { + try { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + const body = await request.json(); + const { provider, credentials } = body; + + if (!provider || !credentials) { + return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 }); + } + + // Validate API key + const isValid = await validateApiKey(apiKey); + if (!isValid) { + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + } + + // Find active connection for provider + const connections = await getProviderConnections({ provider, isActive: true }); + const connection = connections[0]; + + if (!connection) { + return NextResponse.json( + { error: `No active connection found for provider: ${provider}` }, + { status: 404 } + ); + } + + // Update credentials + const updateData = {}; + if (credentials.accessToken) { + updateData.accessToken = credentials.accessToken; + } + if (credentials.refreshToken) { + updateData.refreshToken = credentials.refreshToken; + } + if (credentials.expiresIn) { + updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString(); + } + + await updateProviderConnection(connection.id, updateData); + + return NextResponse.json({ + success: true, + message: `Credentials updated for provider: ${provider}`, + }); + } catch (error) { + console.log("Update credentials error:", error); + return NextResponse.json({ error: "Failed to update credentials" }, { status: 500 }); + } +} diff --git a/src/app/api/cloud/model/resolve/route.js b/src/app/api/cloud/model/resolve/route.js new file mode 100644 index 0000000000..e5f319ac7a --- /dev/null +++ b/src/app/api/cloud/model/resolve/route.js @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { validateApiKey, getModelAliases } from "@/models"; + +// Resolve model alias to provider/model +export async function POST(request) { + try { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + + const body = await request.json(); + const { alias } = body; + + if (!alias) { + return NextResponse.json({ error: "Missing alias" }, { status: 400 }); + } + + // Validate API key + const isValid = await validateApiKey(apiKey); + if (!isValid) { + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + } + + // Get model aliases + const modelAliases = await getModelAliases(); + const resolved = modelAliases[alias]; + + if (resolved) { + // Parse provider/model + const firstSlash = resolved.indexOf("/"); + if (firstSlash > 0) { + return NextResponse.json({ + alias, + provider: resolved.slice(0, firstSlash), + model: resolved.slice(firstSlash + 1), + }); + } + } + + // Not found + return NextResponse.json({ error: "Alias not found" }, { status: 404 }); + } catch (error) { + console.log("Model resolve error:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/cloud/models/alias/route.js b/src/app/api/cloud/models/alias/route.js new file mode 100644 index 0000000000..4903989a85 --- /dev/null +++ b/src/app/api/cloud/models/alias/route.js @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI) +export async function PUT(request) { + try { + const authHeader = request.headers.get("authorization"); + const apiKey = authHeader?.replace("Bearer ", ""); + + if (!apiKey) { + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + } + + const isValid = await validateApiKey(apiKey); + if (!isValid) { + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + } + + const body = await request.json(); + const { model, alias } = body; + + if (!model || !alias) { + return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); + } + + // Check if alias already exists for different model + const aliases = await getModelAliases(); + const existingModel = aliases[alias]; + if (existingModel && existingModel !== model) { + return NextResponse.json( + { + error: `Alias '${alias}' already in use for model '${existingModel}'`, + }, + { status: 400 } + ); + } + + // Update alias + await setModelAlias(alias, model); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + model, + alias, + message: `Alias '${alias}' set for model '${model}'`, + }); + } catch (error) { + console.log("Error updating alias:", error); + return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing aliases to cloud:", error); + } +} + +// GET /api/cloud/models/alias - Get all aliases +export async function GET(request) { + try { + const authHeader = request.headers.get("authorization"); + const apiKey = authHeader?.replace("Bearer ", ""); + + if (!apiKey) { + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + } + + const isValid = await validateApiKey(apiKey); + if (!isValid) { + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + } + + const aliases = await getModelAliases(); + + return NextResponse.json({ aliases }); + } catch (error) { + console.log("Error fetching aliases:", error); + return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); + } +} diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js new file mode 100644 index 0000000000..19ff6ff993 --- /dev/null +++ b/src/app/api/combos/[id]/route.js @@ -0,0 +1,120 @@ +import { NextResponse } from "next/server"; +import { + getComboById, + updateCombo, + deleteCombo, + getComboByName, + getCombos, + isCloudEnabled, +} from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { validateComboDAG } from "@omniroute/open-sse/services/combo.js"; + +// Validate combo name: only a-z, A-Z, 0-9, -, _ +const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/; + +// GET /api/combos/[id] - Get combo by ID +export async function GET(request, { params }) { + try { + const { id } = await params; + const combo = await getComboById(id); + + if (!combo) { + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } + + return NextResponse.json(combo); + } catch (error) { + console.log("Error fetching combo:", error); + return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 }); + } +} + +// PUT /api/combos/[id] - Update combo +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + + // Validate name format if provided + if (body.name) { + if (!VALID_NAME_REGEX.test(body.name)) { + return NextResponse.json( + { error: "Name can only contain letters, numbers, - and _" }, + { status: 400 } + ); + } + + // Check if name already exists (exclude current combo) + const existing = await getComboByName(body.name); + if (existing && existing.id !== id) { + return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + } + } + + // Validate nested combo DAG (no circular references, max depth) + if (body.models) { + const allCombos = await getCombos(); + // Update the combo in the list temporarily for validation + const updatedCombos = allCombos.map((c) => (c.id === id ? { ...c, ...body } : c)); + const comboName = body.name || (await getComboById(id))?.name; + if (comboName) { + try { + validateComboDAG(comboName, updatedCombos); + } catch (dagError) { + return NextResponse.json({ error: dagError.message }, { status: 400 }); + } + } + } + + const combo = await updateCombo(id, body); + + if (!combo) { + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json(combo); + } catch (error) { + console.log("Error updating combo:", error); + return NextResponse.json({ error: "Failed to update combo" }, { status: 500 }); + } +} + +// DELETE /api/combos/[id] - Delete combo +export async function DELETE(request, { params }) { + try { + const { id } = await params; + const success = await deleteCombo(id); + + if (!success) { + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.log("Error deleting combo:", error); + return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud:", error); + } +} diff --git a/src/app/api/combos/metrics/route.js b/src/app/api/combos/metrics/route.js new file mode 100644 index 0000000000..e57b1884d3 --- /dev/null +++ b/src/app/api/combos/metrics/route.js @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { + getAllComboMetrics, + getComboMetrics, + resetComboMetrics, + resetAllComboMetrics, +} from "@omniroute/open-sse/services/comboMetrics.js"; + +// GET /api/combos/metrics - Get per-combo metrics +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const comboName = searchParams.get("combo"); + + if (comboName) { + const metrics = getComboMetrics(comboName); + if (!metrics) { + return NextResponse.json({ metrics: null, message: "No metrics for this combo yet" }); + } + return NextResponse.json({ metrics }); + } + + const allMetrics = getAllComboMetrics(); + return NextResponse.json({ metrics: allMetrics }); + } catch (error) { + console.log("Error fetching combo metrics:", error); + return NextResponse.json({ error: "Failed to fetch combo metrics" }, { status: 500 }); + } +} + +// DELETE /api/combos/metrics - Reset metrics +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const comboName = searchParams.get("combo"); + + if (comboName) { + resetComboMetrics(comboName); + return NextResponse.json({ success: true, message: `Metrics reset for ${comboName}` }); + } + + resetAllComboMetrics(); + return NextResponse.json({ success: true, message: "All combo metrics reset" }); + } catch (error) { + console.log("Error resetting combo metrics:", error); + return NextResponse.json({ error: "Failed to reset combo metrics" }, { status: 500 }); + } +} diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js new file mode 100644 index 0000000000..6a3699585f --- /dev/null +++ b/src/app/api/combos/route.js @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { createComboSchema, validateBody } from "@/shared/validation/schemas"; +import { validateComboDAG } from "@omniroute/open-sse/services/combo.js"; + +// GET /api/combos - Get all combos +export async function GET() { + try { + const combos = await getCombos(); + return NextResponse.json({ combos }); + } catch (error) { + console.log("Error fetching combos:", error); + return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 }); + } +} + +// POST /api/combos - Create new combo +export async function POST(request) { + try { + const body = await request.json(); + + // Zod validation (covers name format, length, etc.) + const validation = validateBody(createComboSchema, body); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { name, models, strategy, config } = validation.data; + + // Check if name already exists + const existing = await getComboByName(name); + if (existing) { + return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + } + + // Validate nested combo DAG (no circular references, max depth) + const allCombos = await getCombos(); + // Temporarily add the new combo to validate its graph + const tempCombo = { name, models: models || [], strategy, config }; + try { + validateComboDAG(name, [...allCombos, tempCombo]); + } catch (dagError) { + return NextResponse.json({ error: dagError.message }, { status: 400 }); + } + + const combo = await createCombo({ name, models: models || [], strategy, config }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json(combo, { status: 201 }); + } catch (error) { + console.log("Error creating combo:", error); + return NextResponse.json({ error: "Failed to create combo" }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud:", error); + } +} diff --git a/src/app/api/combos/test/route.js b/src/app/api/combos/test/route.js new file mode 100644 index 0000000000..bcb5962e25 --- /dev/null +++ b/src/app/api/combos/test/route.js @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { getComboByName } from "@/lib/localDb"; + +/** + * POST /api/combos/test - Quick test a combo + * Sends a minimal request through each model in the combo to verify availability + */ +export async function POST(request) { + try { + const { comboName } = await request.json(); + + if (!comboName) { + return NextResponse.json({ error: "comboName is required" }, { status: 400 }); + } + + const combo = await getComboByName(comboName); + if (!combo) { + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } + + const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model)); + + if (models.length === 0) { + return NextResponse.json({ error: "Combo has no models" }, { status: 400 }); + } + + const results = []; + let resolvedBy = null; + + // Test each model sequentially + for (const modelStr of models) { + const startTime = Date.now(); + try { + // Send a minimal chat request to the internal SSE handler + const testBody = { + model: modelStr, + messages: [{ role: "user", content: "Hi" }], + max_tokens: 5, + stream: false, + }; + + const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout + + const res = await fetch(internalUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(testBody), + signal: controller.signal, + }); + + clearTimeout(timeout); + const latencyMs = Date.now() - startTime; + + if (res.ok) { + results.push({ model: modelStr, status: "ok", latencyMs }); + if (!resolvedBy) resolvedBy = modelStr; + // For test, we can stop after first success (like a real combo would) + // But let's test all models to show full health + } else { + let errorMsg = ""; + try { + const errBody = await res.json(); + errorMsg = errBody?.error?.message || errBody?.error || res.statusText; + } catch { + errorMsg = res.statusText; + } + results.push({ + model: modelStr, + status: "error", + statusCode: res.status, + error: errorMsg, + latencyMs, + }); + } + } catch (error) { + const latencyMs = Date.now() - startTime; + results.push({ + model: modelStr, + status: "error", + error: error.name === "AbortError" ? "Timeout (15s)" : error.message, + latencyMs, + }); + } + } + + return NextResponse.json({ + comboName, + strategy: combo.strategy || "priority", + resolvedBy, + results, + testedAt: new Date().toISOString(), + }); + } catch (error) { + console.log("Error testing combo:", error); + return NextResponse.json({ error: "Failed to test combo" }, { status: 500 }); + } +} + +/** + * Get the base URL for internal requests + */ +function getBaseUrl(request) { + const url = new URL(request.url); + return `${url.protocol}//${url.host}`; +} diff --git a/src/app/api/db-backups/route.js b/src/app/api/db-backups/route.js new file mode 100644 index 0000000000..ece76011f9 --- /dev/null +++ b/src/app/api/db-backups/route.js @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb"; + +/** + * PUT /api/db-backups — Trigger a manual backup snapshot. + */ +export async function PUT() { + try { + const result = backupDbFile("manual"); + if (!result) { + return NextResponse.json({ message: "No changes since last backup (throttled)" }); + } + return NextResponse.json({ created: true, ...result }); + } catch (error) { + console.error("[API] Error creating manual backup:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * GET /api/db-backups — List available database backups. + */ +export async function GET() { + try { + const backups = await listDbBackups(); + return NextResponse.json({ backups }); + } catch (error) { + console.error("[API] Error listing DB backups:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * POST /api/db-backups — Restore a specific backup. + * Body: { backupId: "db_2026-02-11T14-00-00-000Z_pre-write.json" } + */ +export async function POST(request) { + try { + const body = await request.json(); + const { backupId } = body; + + if (!backupId) { + return NextResponse.json({ error: "backupId is required" }, { status: 400 }); + } + + const result = await restoreDbBackup(backupId); + return NextResponse.json(result); + } catch (error) { + console.error("[API] Error restoring DB backup:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/init/route.js b/src/app/api/init/route.js new file mode 100644 index 0000000000..2630babfae --- /dev/null +++ b/src/app/api/init/route.js @@ -0,0 +1,7 @@ +// Auto-initialize cloud sync when server starts +import "@/lib/initCloudSync"; + +// This API route is called automatically to initialize sync +export async function GET() { + return new Response("Initialized", { status: 200 }); +} diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js new file mode 100644 index 0000000000..6a1b4e3d0f --- /dev/null +++ b/src/app/api/keys/[id]/route.js @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { deleteApiKey, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +// DELETE /api/keys/[id] - Delete API key +export async function DELETE(request, { params }) { + try { + const { id } = await params; + + const deleted = await deleteApiKey(id); + if (!deleted) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncKeysToCloudIfEnabled(); + + return NextResponse.json({ message: "Key deleted successfully" }); + } catch (error) { + console.log("Error deleting key:", error); + return NextResponse.json({ error: "Failed to delete key" }, { status: 500 }); + } +} + +/** + * Sync API keys to Cloud if enabled + */ +async function syncKeysToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing keys to cloud:", error); + } +} diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js new file mode 100644 index 0000000000..8bff3d51ee --- /dev/null +++ b/src/app/api/keys/route.js @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { createKeySchema, validateBody } from "@/shared/validation/schemas"; + +// GET /api/keys - List API keys +export async function GET() { + try { + const keys = await getApiKeys(); + return NextResponse.json({ keys }); + } catch (error) { + console.log("Error fetching keys:", error); + return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); + } +} + +// POST /api/keys - Create new API key +export async function POST(request) { + try { + const body = await request.json(); + + // Zod validation + const validation = validateBody(createKeySchema, body); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { name } = validation.data; + + // Always get machineId from server + const machineId = await getConsistentMachineId(); + const apiKey = await createApiKey(name, machineId); + + // Auto sync to Cloud if enabled + await syncKeysToCloudIfEnabled(); + + return NextResponse.json( + { + key: apiKey.key, + name: apiKey.name, + id: apiKey.id, + machineId: apiKey.machineId, + }, + { status: 201 } + ); + } catch (error) { + console.log("Error creating key:", error); + return NextResponse.json({ error: "Failed to create key" }, { status: 500 }); + } +} + +/** + * Sync API keys to Cloud if enabled + */ +async function syncKeysToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing keys to cloud:", error); + } +} diff --git a/src/app/api/models/alias/route.js b/src/app/api/models/alias/route.js new file mode 100644 index 0000000000..63c8813c0b --- /dev/null +++ b/src/app/api/models/alias/route.js @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +// GET /api/models/alias - Get all aliases +export async function GET() { + try { + const aliases = await getModelAliases(); + return NextResponse.json({ aliases }); + } catch (error) { + console.log("Error fetching aliases:", error); + return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); + } +} + +// PUT /api/models/alias - Set model alias +export async function PUT(request) { + try { + const body = await request.json(); + const { model, alias } = body; + + if (!model || !alias) { + return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); + } + + await setModelAlias(alias, model); + await syncToCloudIfEnabled(); + + return NextResponse.json({ success: true, model, alias }); + } catch (error) { + console.log("Error updating alias:", error); + return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); + } +} + +// DELETE /api/models/alias?alias=xxx - Delete alias +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const alias = searchParams.get("alias"); + + if (!alias) { + return NextResponse.json({ error: "Alias required" }, { status: 400 }); + } + + await deleteModelAlias(alias); + await syncToCloudIfEnabled(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.log("Error deleting alias:", error); + return NextResponse.json({ error: "Failed to delete alias" }, { status: 500 }); + } +} + +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing aliases to cloud:", error); + } +} diff --git a/src/app/api/models/catalog/route.js b/src/app/api/models/catalog/route.js new file mode 100644 index 0000000000..bb741403cb --- /dev/null +++ b/src/app/api/models/catalog/route.js @@ -0,0 +1,108 @@ +import { getProviderConnections, getAllCustomModels } from "@/lib/localDb"; +import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.js"; +import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.js"; +import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers"; + +/** + * GET /api/models/catalog + * Returns all models grouped by provider, with metadata (type, custom flag) + */ +export async function GET() { + try { + const connections = await getProviderConnections(); + const activeProviders = new Set(connections.map((c) => c.provider)); + const customModelsMap = await getAllCustomModels().catch(() => ({})); + + const catalog = {}; + + // Built-in chat models + for (const [alias, models] of Object.entries(PROVIDER_MODELS)) { + const providerId = ALIAS_TO_ID[alias] || alias; + if (!catalog[alias]) { + catalog[alias] = { + provider: AI_PROVIDERS[providerId]?.name || alias, + active: activeProviders.has(providerId), + models: [], + }; + } + + for (const model of models) { + catalog[alias].models.push({ + id: `${alias}/${model.id}`, + name: model.name, + type: "chat", + custom: false, + }); + } + } + + // Embedding models + for (const emb of getAllEmbeddingModels()) { + const parts = emb.id.split("/"); + const provAlias = parts[0]; + if (!catalog[provAlias]) { + catalog[provAlias] = { + provider: provAlias, + active: false, + models: [], + }; + } + catalog[provAlias].models.push({ + id: emb.id, + name: emb.name || emb.id, + type: "embedding", + custom: false, + }); + } + + // Image models + for (const img of getAllImageModels()) { + const provAlias = img.provider; + if (!catalog[provAlias]) { + catalog[provAlias] = { + provider: provAlias, + active: false, + models: [], + }; + } + catalog[provAlias].models.push({ + id: img.id, + name: img.name || img.id, + type: "image", + custom: false, + }); + } + + // Custom models + for (const [providerId, models] of Object.entries(customModelsMap)) { + const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + if (!catalog[alias]) { + catalog[alias] = { + provider: AI_PROVIDERS[providerId]?.name || alias, + active: activeProviders.has(providerId), + models: [], + }; + } + + for (const model of models) { + const fullId = `${alias}/${model.id}`; + // Skip duplicates + if (catalog[alias].models.some((m) => m.id === fullId)) continue; + catalog[alias].models.push({ + id: fullId, + name: model.name || model.id, + type: "chat", + custom: true, + }); + } + } + + return Response.json({ catalog }); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js new file mode 100644 index 0000000000..6c77c81651 --- /dev/null +++ b/src/app/api/models/route.js @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { getModelAliases, setModelAlias } from "@/models"; +import { AI_MODELS } from "@/shared/constants/config"; + +// GET /api/models - Get models with aliases +export async function GET() { + try { + const modelAliases = await getModelAliases(); + + const models = AI_MODELS.map((m) => { + const fullModel = `${m.provider}/${m.model}`; + return { + ...m, + fullModel, + alias: modelAliases[fullModel] || m.model, + }; + }); + + return NextResponse.json({ models }); + } catch (error) { + console.log("Error fetching models:", error); + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); + } +} + +// PUT /api/models - Update model alias +export async function PUT(request) { + try { + const body = await request.json(); + const { model, alias } = body; + + if (!model || !alias) { + return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); + } + + const modelAliases = await getModelAliases(); + + // Check if alias already exists for different model + const existingModel = Object.entries(modelAliases).find( + ([key, val]) => val === alias && key !== model + ); + + if (existingModel) { + return NextResponse.json({ error: "Alias already in use" }, { status: 400 }); + } + + // Update alias + await setModelAlias(model, alias); + + return NextResponse.json({ success: true, model, alias }); + } catch (error) { + console.log("Error updating alias:", error); + return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js new file mode 100644 index 0000000000..68b939e9ab --- /dev/null +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -0,0 +1,348 @@ +import { NextResponse } from "next/server"; +import { + getProvider, + generateAuthData, + exchangeTokens, + requestDeviceCode, + pollForToken, +} from "@/lib/oauth/providers"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { startLocalServer } from "@/lib/oauth/utils/server"; + +// Use globalThis to persist callback server state across Next.js HMR reloads +if (!globalThis.__codexCallbackState) { + globalThis.__codexCallbackState = null; +} + +/** + * Dynamic OAuth API Route + * Handles: authorize, exchange, device-code, poll, start-callback-server, poll-callback + */ + +// GET /api/oauth/[provider]/authorize - Generate auth URL +// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow) +export async function GET(request, { params }) { + try { + const { provider, action } = await params; + const { searchParams } = new URL(request.url); + + if (action === "authorize") { + const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback"; + const authData = generateAuthData(provider, redirectUri); + return NextResponse.json(authData); + } + + if (action === "device-code") { + const providerData = getProvider(provider); + if (providerData.flowType !== "device_code") { + return NextResponse.json( + { error: "Provider does not support device code flow" }, + { status: 400 } + ); + } + + const authData = generateAuthData(provider, null); + + // For providers that don't use PKCE (like GitHub), don't pass codeChallenge + let deviceData; + if (provider === "github" || provider === "kiro" || provider === "kilocode") { + // GitHub, Kiro, and KiloCode don't use PKCE for device code + deviceData = await requestDeviceCode(provider); + } else { + // Qwen and other providers use PKCE + deviceData = await requestDeviceCode(provider, authData.codeChallenge); + } + + return NextResponse.json({ + ...deviceData, + codeVerifier: authData.codeVerifier, + }); + } + + if (action === "start-callback-server") { + return await handleStartCallbackServer(provider, searchParams); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (error) { + console.log("OAuth GET error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Start Codex callback server on port 1455 + * Returns the auth URL and stores codeVerifier for later exchange + */ +async function handleStartCallbackServer(provider, searchParams) { + if (provider !== "codex") { + return NextResponse.json( + { error: "Callback server only supported for codex" }, + { status: 400 } + ); + } + + // Clean up existing server if any + if (globalThis.__codexCallbackState?.close) { + try { + globalThis.__codexCallbackState.close(); + } catch (e) { + /* ignore */ + } + } + globalThis.__codexCallbackState = null; + + try { + // Start temp server on port 1455 + const { port, close } = await startLocalServer((params) => { + // Write directly to globalThis so it survives module reloads + if (globalThis.__codexCallbackState) { + globalThis.__codexCallbackState.callbackParams = params; + } + }, 1455); + + const redirectUri = `http://localhost:${port}/auth/callback`; + const authData = generateAuthData(provider, redirectUri); + + globalThis.__codexCallbackState = { + callbackParams: null, + close, + port, + redirectUri, + codeVerifier: authData.codeVerifier, + startedAt: Date.now(), + }; + + // Auto-cleanup after 5 minutes + const startedAt = Date.now(); + setTimeout(() => { + if (globalThis.__codexCallbackState?.startedAt === startedAt) { + try { + close(); + } catch (e) { + /* ignore */ + } + globalThis.__codexCallbackState = null; + } + }, 300000); + + return NextResponse.json({ + authUrl: authData.authUrl, + codeVerifier: authData.codeVerifier, + redirectUri, + serverPort: port, + }); + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save +// POST /api/oauth/[provider]/poll - Poll for token (device_code flow) +export async function POST(request, { params }) { + try { + const { provider, action } = await params; + const body = await request.json(); + + if (action === "exchange") { + const { code, redirectUri, codeVerifier, state } = body; + + if (!code || !redirectUri || !codeVerifier) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + // Exchange code for tokens + const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state); + + // Save to database + const connection = await createProviderConnection({ + provider, + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } + + if (action === "poll") { + const { deviceCode, codeVerifier, extraData } = body; + + if (!deviceCode) { + return NextResponse.json({ error: "Missing device code" }, { status: 400 }); + } + + // For providers that don't use PKCE (like GitHub, Kiro, Kimi Coding), don't pass codeVerifier + let result; + if (provider === "github" || provider === "kimi-coding" || provider === "kilocode") { + result = await pollForToken(provider, deviceCode); + } else if (provider === "kiro") { + // Kiro needs extraData (clientId, clientSecret) from device code response + result = await pollForToken(provider, deviceCode, null, extraData); + } else { + // Qwen and other providers use PKCE + if (!codeVerifier) { + return NextResponse.json({ error: "Missing code verifier" }, { status: 400 }); + } + result = await pollForToken(provider, deviceCode, codeVerifier); + } + + if (result.success) { + // Save to database + const connection = await createProviderConnection({ + provider, + authType: "oauth", + ...result.tokens, + expiresAt: result.tokens.expiresIn + ? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + }, + }); + } + + // Still pending or error - don't create connection for pending states + const isPending = + result.pending || result.error === "authorization_pending" || result.error === "slow_down"; + + return NextResponse.json({ + success: false, + error: result.error, + errorDescription: result.errorDescription, + pending: isPending, + }); + } + + if (action === "poll-callback") { + // Poll for Codex callback server result + if (provider !== "codex") { + return NextResponse.json( + { error: "poll-callback only supported for codex" }, + { status: 400 } + ); + } + + if (!globalThis.__codexCallbackState) { + return NextResponse.json({ + success: false, + error: "no_server", + errorDescription: "Callback server not running", + }); + } + + if (!globalThis.__codexCallbackState.callbackParams) { + return NextResponse.json({ success: false, pending: true }); + } + + // Callback received! Extract code and exchange for tokens + const params = globalThis.__codexCallbackState.callbackParams; + const { redirectUri, codeVerifier, close } = globalThis.__codexCallbackState; + + // Clean up server + try { + close(); + } catch (e) { + /* ignore */ + } + globalThis.__codexCallbackState = null; + + if (params.error) { + return NextResponse.json({ + success: false, + error: params.error, + errorDescription: params.error_description, + }); + } + + if (!params.code) { + return NextResponse.json({ + success: false, + error: "no_code", + errorDescription: "No authorization code received", + }); + } + + try { + // Exchange code for tokens + const tokenData = await exchangeTokens( + provider, + params.code, + redirectUri, + codeVerifier, + params.state + ); + + // Save to database + const connection = await createProviderConnection({ + provider, + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (exchangeErr) { + return NextResponse.json({ success: false, error: exchangeErr.message }, { status: 500 }); + } + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (error) { + console.log("OAuth POST error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after OAuth:", error); + } +} diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js new file mode 100644 index 0000000000..2953137037 --- /dev/null +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; +import { homedir } from "os"; +import { join } from "path"; +import Database from "better-sqlite3"; + +/** + * GET /api/oauth/cursor/auto-import + * Auto-detect and extract Cursor tokens from local SQLite database + */ +export async function GET() { + try { + const platform = process.platform; + let dbPath; + + // Determine database path based on platform + if (platform === "darwin") { + dbPath = join(homedir(), "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "linux") { + dbPath = join(homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "win32") { + dbPath = join(process.env.APPDATA || "", "Cursor/User/globalStorage/state.vscdb"); + } else { + return NextResponse.json({ error: "Unsupported platform", found: false }, { status: 400 }); + } + + // Try to open database + let db; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + } catch (error) { + return NextResponse.json({ + found: false, + error: + "Cursor database not found. Make sure Cursor IDE is installed and you are logged in.", + }); + } + + try { + // Extract tokens from database + const rows = db + .prepare("SELECT key, value FROM itemTable WHERE key IN (?, ?)") + .all("cursorAuth/accessToken", "storage.serviceMachineId"); + + const tokens = {}; + for (const row of rows) { + if (row.key === "cursorAuth/accessToken") { + tokens.accessToken = row.value; + } else if (row.key === "storage.serviceMachineId") { + tokens.machineId = row.value; + } + } + + db.close(); + + // Validate tokens exist + if (!tokens.accessToken || !tokens.machineId) { + return NextResponse.json({ + found: false, + error: "Tokens not found in database. Please login to Cursor IDE first.", + }); + } + + return NextResponse.json({ + found: true, + accessToken: tokens.accessToken, + machineId: tokens.machineId, + }); + } catch (error) { + db?.close(); + return NextResponse.json({ + found: false, + error: `Failed to read database: ${error.message}`, + }); + } + } catch (error) { + console.log("Cursor auto-import error:", error); + return NextResponse.json({ found: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/cursor/import/route.js b/src/app/api/oauth/cursor/import/route.js new file mode 100644 index 0000000000..d3e02da974 --- /dev/null +++ b/src/app/api/oauth/cursor/import/route.js @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { CursorService } from "@/lib/oauth/services/cursor"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +/** + * POST /api/oauth/cursor/import + * Import and validate access token from Cursor IDE's local SQLite database + * + * Request body: + * - accessToken: string - Access token from cursorAuth/accessToken + * - machineId: string - Machine ID from storage.serviceMachineId + */ +export async function POST(request) { + try { + const { accessToken, machineId } = await request.json(); + + if (!accessToken || typeof accessToken !== "string") { + return NextResponse.json({ error: "Access token is required" }, { status: 400 }); + } + + if (!machineId || typeof machineId !== "string") { + return NextResponse.json({ error: "Machine ID is required" }, { status: 400 }); + } + + const cursorService = new CursorService(); + + // Validate token by making API call + const tokenData = await cursorService.validateImportToken(accessToken.trim(), machineId.trim()); + + // Try to extract user info from token + const userInfo = cursorService.extractUserInfo(tokenData.accessToken); + + // Save to database + const connection = await createProviderConnection({ + provider: "cursor", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email: userInfo?.email || null, + providerSpecificData: { + machineId: tokenData.machineId, + authMethod: "imported", + provider: "Imported", + userId: userInfo?.userId, + }, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Cursor import token error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * GET /api/oauth/cursor/import + * Get instructions for importing Cursor token + */ +export async function GET() { + const cursorService = new CursorService(); + const instructions = cursorService.getTokenStorageInstructions(); + + return NextResponse.json({ + provider: "cursor", + method: "import_token", + instructions, + requiredFields: [ + { + name: "accessToken", + label: "Access Token", + description: "From cursorAuth/accessToken in state.vscdb", + type: "textarea", + }, + { + name: "machineId", + label: "Machine ID", + description: "From storage.serviceMachineId in state.vscdb", + type: "text", + }, + ], + }); +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Cursor import:", error); + } +} diff --git a/src/app/api/oauth/kiro/auto-import/route.js b/src/app/api/oauth/kiro/auto-import/route.js new file mode 100644 index 0000000000..1f1cdb9f42 --- /dev/null +++ b/src/app/api/oauth/kiro/auto-import/route.js @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { readFile, readdir } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; + +/** + * GET /api/oauth/kiro/auto-import + * Auto-detect and extract Kiro refresh token from AWS SSO cache + */ +export async function GET() { + try { + const cachePath = join(homedir(), ".aws/sso/cache"); + + // Try to read cache directory + let files; + try { + files = await readdir(cachePath); + } catch (error) { + return NextResponse.json({ + found: false, + error: "AWS SSO cache not found. Please login to Kiro IDE first.", + }); + } + + // Look for kiro-auth-token.json or any .json file with refreshToken + let refreshToken = null; + let foundFile = null; + + // First try kiro-auth-token.json + const kiroTokenFile = "kiro-auth-token.json"; + if (files.includes(kiroTokenFile)) { + try { + const content = await readFile(join(cachePath, kiroTokenFile), "utf-8"); + const data = JSON.parse(content); + if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { + refreshToken = data.refreshToken; + foundFile = kiroTokenFile; + } + } catch (error) { + // Continue to search other files + } + } + + // If not found, search all .json files + if (!refreshToken) { + for (const file of files) { + if (!file.endsWith(".json")) continue; + + try { + const content = await readFile(join(cachePath, file), "utf-8"); + const data = JSON.parse(content); + + // Look for Kiro refresh token (starts with aorAAAAAG) + if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { + refreshToken = data.refreshToken; + foundFile = file; + break; + } + } catch (error) { + // Skip invalid JSON files + continue; + } + } + } + + if (!refreshToken) { + return NextResponse.json({ + found: false, + error: "Kiro token not found in AWS SSO cache. Please login to Kiro IDE first.", + }); + } + + return NextResponse.json({ + found: true, + refreshToken, + source: foundFile, + }); + } catch (error) { + console.log("Kiro auto-import error:", error); + return NextResponse.json({ found: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js new file mode 100644 index 0000000000..2dd54e0cd8 --- /dev/null +++ b/src/app/api/oauth/kiro/import/route.js @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +/** + * POST /api/oauth/kiro/import + * Import and validate refresh token from Kiro IDE + */ +export async function POST(request) { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken || typeof refreshToken !== "string") { + return NextResponse.json({ error: "Refresh token is required" }, { status: 400 }); + } + + const kiroService = new KiroService(); + + // Validate and refresh token + const tokenData = await kiroService.validateImportToken(refreshToken.trim()); + + // Extract email from JWT if available + const email = kiroService.extractEmailFromJWT(tokenData.accessToken); + + // Save to database + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: tokenData.profileArn, + authMethod: "imported", + provider: "Imported", + }, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Kiro import token error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro import:", error); + } +} diff --git a/src/app/api/oauth/kiro/social-authorize/route.js b/src/app/api/oauth/kiro/social-authorize/route.js new file mode 100644 index 0000000000..53408f2ed8 --- /dev/null +++ b/src/app/api/oauth/kiro/social-authorize/route.js @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { generatePKCE } from "@/lib/oauth/utils/pkce"; +import { KiroService } from "@/lib/oauth/services/kiro"; + +/** + * GET /api/oauth/kiro/social-authorize + * Generate Google/GitHub social login URL for manual callback flow + * Uses kiro:// custom protocol as required by AWS Cognito + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); // "google" or "github" + + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.json( + { error: "Invalid provider. Use 'google' or 'github'" }, + { status: 400 } + ); + } + + // Generate PKCE for social auth + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + const kiroService = new KiroService(); + const authUrl = kiroService.buildSocialLoginUrl(provider, codeChallenge, state); + + return NextResponse.json({ + authUrl, + state, + codeVerifier, + codeChallenge, + provider, + }); + } catch (error) { + console.log("Kiro social authorize error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/kiro/social-exchange/route.js b/src/app/api/oauth/kiro/social-exchange/route.js new file mode 100644 index 0000000000..250483e56e --- /dev/null +++ b/src/app/api/oauth/kiro/social-exchange/route.js @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +/** + * POST /api/oauth/kiro/social-exchange + * Exchange authorization code for tokens (Google/GitHub social login) + * Callback URL will be in format: kiro://kiro.kiroAgent/authenticate-success?code=XXX&state=YYY + */ +export async function POST(request) { + try { + const { code, codeVerifier, provider } = await request.json(); + + if (!code || !codeVerifier) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + const kiroService = new KiroService(); + + // Exchange code for tokens (redirect_uri handled internally) + const tokenData = await kiroService.exchangeSocialCode(code, codeVerifier); + + // Extract email from JWT if available + const email = kiroService.extractEmailFromJWT(tokenData.accessToken); + + // Save to database + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: tokenData.profileArn, + authMethod: provider, // "google" or "github" + provider: provider.charAt(0).toUpperCase() + provider.slice(1), + }, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Kiro social exchange error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro OAuth:", error); + } +} diff --git a/src/app/api/pricing/defaults/route.js b/src/app/api/pricing/defaults/route.js new file mode 100644 index 0000000000..fae6dc2230 --- /dev/null +++ b/src/app/api/pricing/defaults/route.js @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getDefaultPricing } from "@/shared/constants/pricing.js"; + +/** + * GET /api/pricing/defaults + * Get default pricing configuration + */ +export async function GET() { + try { + const defaultPricing = getDefaultPricing(); + return NextResponse.json(defaultPricing); + } catch (error) { + console.error("Error fetching default pricing:", error); + return NextResponse.json({ error: "Failed to fetch default pricing" }, { status: 500 }); + } +} diff --git a/src/app/api/pricing/route.js b/src/app/api/pricing/route.js new file mode 100644 index 0000000000..414a4c4733 --- /dev/null +++ b/src/app/api/pricing/route.js @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js"; + +/** + * GET /api/pricing + * Get current pricing configuration (merged user + defaults) + */ +export async function GET() { + try { + const pricing = await getPricing(); + return NextResponse.json(pricing); + } catch (error) { + console.error("Error fetching pricing:", error); + return NextResponse.json({ error: "Failed to fetch pricing" }, { status: 500 }); + } +} + +/** + * PATCH /api/pricing + * Update pricing configuration + * Body: { provider: { model: { input: number, output: number, cached: number, ... } } } + */ +export async function PATCH(request) { + try { + const body = await request.json(); + + // Validate body structure + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Invalid pricing data format" }, { status: 400 }); + } + + // Validate pricing structure + for (const [provider, models] of Object.entries(body)) { + if (typeof models !== "object" || models === null) { + return NextResponse.json( + { error: `Invalid pricing for provider: ${provider}` }, + { status: 400 } + ); + } + + for (const [model, pricing] of Object.entries(models)) { + if (typeof pricing !== "object" || pricing === null) { + return NextResponse.json( + { error: `Invalid pricing for model: ${provider}/${model}` }, + { status: 400 } + ); + } + + // Validate pricing fields + const validFields = ["input", "output", "cached", "reasoning", "cache_creation"]; + for (const [key, value] of Object.entries(pricing)) { + if (!validFields.includes(key)) { + return NextResponse.json( + { error: `Invalid pricing field: ${key} for ${provider}/${model}` }, + { status: 400 } + ); + } + if (typeof value !== "number" || isNaN(value) || value < 0) { + return NextResponse.json( + { + error: `Invalid pricing value for ${key} in ${provider}/${model}: must be non-negative number`, + }, + { status: 400 } + ); + } + } + } + } + + const updatedPricing = await updatePricing(body); + return NextResponse.json(updatedPricing); + } catch (error) { + console.error("Error updating pricing:", error); + return NextResponse.json({ error: "Failed to update pricing" }, { status: 500 }); + } +} + +/** + * DELETE /api/pricing + * Reset pricing to defaults + * Query params: ?provider=xxx&model=yyy (optional) + */ +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + const model = searchParams.get("model"); + + if (provider && model) { + // Reset specific model + await resetPricing(provider, model); + } else if (provider) { + // Reset entire provider + await resetPricing(provider); + } else { + // Reset all pricing + await resetAllPricing(); + } + + const pricing = await getPricing(); + return NextResponse.json(pricing); + } catch (error) { + console.error("Error resetting pricing:", error); + return NextResponse.json({ error: "Failed to reset pricing" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-models/route.js b/src/app/api/provider-models/route.js new file mode 100644 index 0000000000..f287a9c900 --- /dev/null +++ b/src/app/api/provider-models/route.js @@ -0,0 +1,83 @@ +import { + getCustomModels, + getAllCustomModels, + addCustomModel, + removeCustomModel, +} from "@/lib/localDb"; + +/** + * GET /api/provider-models?provider= + * List custom models (all providers if no provider param) + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + + const models = provider ? await getCustomModels(provider) : await getAllCustomModels(); + + return Response.json({ models }); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} + +/** + * POST /api/provider-models + * Body: { provider, modelId, modelName? } + */ +export async function POST(request) { + try { + const body = await request.json(); + const { provider, modelId, modelName } = body; + + if (!provider || !modelId) { + return Response.json( + { error: { message: "provider and modelId are required", type: "validation_error" } }, + { status: 400 } + ); + } + + const model = await addCustomModel(provider, modelId, modelName); + return Response.json({ model }); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/provider-models?provider=&model= + */ +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + const modelId = searchParams.get("model"); + + if (!provider || !modelId) { + return Response.json( + { + error: { + message: "provider and model query params are required", + type: "validation_error", + }, + }, + { status: 400 } + ); + } + + const removed = await removeCustomModel(provider, modelId); + return Response.json({ removed }); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js new file mode 100644 index 0000000000..d4f081285b --- /dev/null +++ b/src/app/api/provider-nodes/[id]/route.js @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { + deleteProviderConnectionsByProvider, + deleteProviderNode, + getProviderConnections, + getProviderNodeById, + updateProviderConnection, + updateProviderNode, +} from "@/models"; + +// PUT /api/provider-nodes/[id] - Update provider node +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { name, prefix, apiType, baseUrl } = body; + const node = await getProviderNodeById(id); + + if (!node) { + return NextResponse.json({ error: "Provider node not found" }, { status: 404 }); + } + + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!prefix?.trim()) { + return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); + } + + // Only validate apiType for OpenAI Compatible nodes + if ( + node.type === "openai-compatible" && + (!apiType || !["chat", "responses"].includes(apiType)) + ) { + return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + } + + if (!baseUrl?.trim()) { + return NextResponse.json({ error: "Base URL is required" }, { status: 400 }); + } + + let sanitizedBaseUrl = baseUrl.trim(); + + // Sanitize Base URL for Anthropic Compatible + if (node.type === "anthropic-compatible") { + sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, ""); + if (sanitizedBaseUrl.endsWith("/messages")) { + sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages + } + } + + const updates = { + name: name.trim(), + prefix: prefix.trim(), + baseUrl: sanitizedBaseUrl, + }; + + if (node.type === "openai-compatible") { + updates.apiType = apiType; + } + + const updated = await updateProviderNode(id, updates); + + const connections = await getProviderConnections({ provider: id }); + await Promise.all( + connections.map((connection) => + updateProviderConnection(connection.id, { + providerSpecificData: { + ...(connection.providerSpecificData || {}), + prefix: prefix.trim(), + apiType: node.type === "openai-compatible" ? apiType : undefined, + baseUrl: sanitizedBaseUrl, + nodeName: updated.name, + }, + }) + ) + ); + + return NextResponse.json({ node: updated }); + } catch (error) { + console.log("Error updating provider node:", error); + return NextResponse.json({ error: "Failed to update provider node" }, { status: 500 }); + } +} + +// DELETE /api/provider-nodes/[id] - Delete provider node and its connections +export async function DELETE(request, { params }) { + try { + const { id } = await params; + const node = await getProviderNodeById(id); + + if (!node) { + return NextResponse.json({ error: "Provider node not found" }, { status: 404 }); + } + + await deleteProviderConnectionsByProvider(id); + await deleteProviderNode(id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.log("Error deleting provider node:", error); + return NextResponse.json({ error: "Failed to delete provider node" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js new file mode 100644 index 0000000000..d7274f53ed --- /dev/null +++ b/src/app/api/provider-nodes/route.js @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { createProviderNode, getProviderNodes } from "@/models"; +import { + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; +import { generateId } from "@/shared/utils"; + +const OPENAI_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.openai.com/v1", +}; + +const ANTHROPIC_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.anthropic.com/v1", +}; + +// GET /api/provider-nodes - List all provider nodes +export async function GET() { + try { + const nodes = await getProviderNodes(); + return NextResponse.json({ nodes }); + } catch (error) { + console.log("Error fetching provider nodes:", error); + return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 }); + } +} + +// POST /api/provider-nodes - Create provider node +export async function POST(request) { + try { + const body = await request.json(); + const { name, prefix, apiType, baseUrl, type } = body; + + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!prefix?.trim()) { + return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); + } + + // Determine type + const nodeType = type || "openai-compatible"; + + if (nodeType === "openai-compatible") { + if (!apiType || !["chat", "responses"].includes(apiType)) { + return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + } + + const node = await createProviderNode({ + id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`, + type: "openai-compatible", + prefix: prefix.trim(), + apiType, + baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), + name: name.trim(), + }); + return NextResponse.json({ node }, { status: 201 }); + } + + if (nodeType === "anthropic-compatible") { + // Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it + // This prevents double-appending /messages at runtime + let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl) + .trim() + .replace(/\/$/, ""); + if (sanitizedBaseUrl.endsWith("/messages")) { + sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages + } + + const node = await createProviderNode({ + id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`, + type: "anthropic-compatible", + prefix: prefix.trim(), + baseUrl: sanitizedBaseUrl, + name: name.trim(), + }); + return NextResponse.json({ node }, { status: 201 }); + } + + return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 }); + } catch (error) { + console.log("Error creating provider node:", error); + return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js new file mode 100644 index 0000000000..85a7d28c16 --- /dev/null +++ b/src/app/api/provider-nodes/validate/route.js @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; + +// POST /api/provider-nodes/validate - Validate API key against base URL +export async function POST(request) { + try { + const body = await request.json(); + const { baseUrl, apiKey, type } = body; + + if (!baseUrl || !apiKey) { + return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 }); + } + + // Anthropic Compatible Validation + if (type === "anthropic-compatible") { + // Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it + let normalizedBase = baseUrl.trim().replace(/\/$/, ""); + if (normalizedBase.endsWith("/messages")) { + normalizedBase = normalizedBase.slice(0, -9); // remove /messages + } + + // Use /models endpoint for validation as many compatible providers support it (like OpenAI) + const modelsUrl = `${normalizedBase}/models`; + + const res = await fetch(modelsUrl, { + method: "GET", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + Authorization: `Bearer ${apiKey}`, // Add Bearer token for hybrid proxies + }, + }); + + return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + } + + // OpenAI Compatible Validation (Default) + const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`; + const res = await fetch(modelsUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + } catch (error) { + console.log("Error validating provider node:", error); + return NextResponse.json({ error: "Validation failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js new file mode 100644 index 0000000000..703f3fd218 --- /dev/null +++ b/src/app/api/providers/[id]/models/route.js @@ -0,0 +1,334 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById } from "@/models"; +import { + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, +} from "@/shared/constants/providers"; + +// Provider models endpoints configuration +const PROVIDER_MODELS_CONFIG = { + claude: { + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: { + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + }, + authHeader: "x-api-key", + parseResponse: (data) => data.data || [], + }, + gemini: { + url: "https://generativelanguage.googleapis.com/v1beta/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authQuery: "key", // Use query param for API key + parseResponse: (data) => data.models || [], + }, + "gemini-cli": { + url: "https://generativelanguage.googleapis.com/v1beta/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.models || [], + }, + qwen: { + url: "https://portal.qwen.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [], + }, + antigravity: { + url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", + method: "POST", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + body: {}, + parseResponse: (data) => data.models || [], + }, + openai: { + url: "https://api.openai.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [], + }, + openrouter: { + url: "https://openrouter.ai/api/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [], + }, + kimi: { + url: "https://api.moonshot.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [], + }, + "kimi-coding": { + url: "https://api.kimi.com/coding/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "x-api-key", + parseResponse: (data) => data.data || data.models || [], + }, + anthropic: { + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: { + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + }, + authHeader: "x-api-key", + parseResponse: (data) => data.data || [], + }, + deepseek: { + url: "https://api.deepseek.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + groq: { + url: "https://api.groq.com/openai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + xai: { + url: "https://api.x.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + mistral: { + url: "https://api.mistral.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + perplexity: { + url: "https://api.perplexity.ai/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + together: { + url: "https://api.together.xyz/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + fireworks: { + url: "https://api.fireworks.ai/inference/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + cerebras: { + url: "https://api.cerebras.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + cohere: { + url: "https://api.cohere.com/v2/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + nvidia: { + url: "https://integrate.api.nvidia.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + nebius: { + url: "https://api.tokenfactory.nebius.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, +}; + +/** + * GET /api/providers/[id]/models - Get models list from provider + */ +export async function GET(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + if (isOpenAICompatibleProvider(connection.provider)) { + const baseUrl = connection.providerSpecificData?.baseUrl; + if (!baseUrl) { + return NextResponse.json( + { error: "No base URL configured for OpenAI compatible provider" }, + { status: 400 } + ); + } + const url = `${baseUrl.replace(/\/$/, "")}/models`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${connection.apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = data.data || data.models || []; + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models, + }); + } + + if (isAnthropicCompatibleProvider(connection.provider)) { + let baseUrl = connection.providerSpecificData?.baseUrl; + if (!baseUrl) { + return NextResponse.json( + { error: "No base URL configured for Anthropic compatible provider" }, + { status: 400 } + ); + } + + baseUrl = baseUrl.replace(/\/$/, ""); + if (baseUrl.endsWith("/messages")) { + baseUrl = baseUrl.slice(0, -9); + } + + const url = `${baseUrl}/models`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + Authorization: `Bearer ${connection.apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = data.data || data.models || []; + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models, + }); + } + + const config = PROVIDER_MODELS_CONFIG[connection.provider]; + if (!config) { + return NextResponse.json( + { error: `Provider ${connection.provider} does not support models listing` }, + { status: 400 } + ); + } + + // Get auth token + const token = connection.accessToken || connection.apiKey; + if (!token) { + return NextResponse.json({ error: "No valid token found" }, { status: 401 }); + } + + // Build request URL + let url = config.url; + if (config.authQuery) { + url += `?${config.authQuery}=${token}`; + } + + // Build headers + const headers = { ...config.headers }; + if (config.authHeader && !config.authQuery) { + headers[config.authHeader] = (config.authPrefix || "") + token; + } + + // Make request + const fetchOptions = { + method: config.method, + headers, + }; + + if (config.body && config.method === "POST") { + fetchOptions.body = JSON.stringify(config.body); + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = config.parseResponse(data); + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models, + }); + } catch (error) { + console.log("Error fetching provider models:", error); + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js new file mode 100644 index 0000000000..96300ace68 --- /dev/null +++ b/src/app/api/providers/[id]/route.js @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; +import { + getProviderConnectionById, + updateProviderConnection, + deleteProviderConnection, + isCloudEnabled, +} from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; + +// GET /api/providers/[id] - Get single connection +export async function GET(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + // Hide sensitive fields + const result = { ...connection }; + delete result.apiKey; + delete result.accessToken; + delete result.refreshToken; + delete result.idToken; + + return NextResponse.json({ connection: result }); + } catch (error) { + console.log("Error fetching connection:", error); + return NextResponse.json({ error: "Failed to fetch connection" }, { status: 500 }); + } +} + +// PUT /api/providers/[id] - Update connection +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { + name, + priority, + globalPriority, + defaultModel, + isActive, + apiKey, + testStatus, + lastError, + lastErrorAt, + lastErrorType, + lastErrorSource, + errorCode, + rateLimitedUntil, + lastTested, + healthCheckInterval, + } = body; + + const existing = await getProviderConnectionById(id); + if (!existing) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (priority !== undefined) updateData.priority = priority; + if (globalPriority !== undefined) updateData.globalPriority = globalPriority; + if (defaultModel !== undefined) updateData.defaultModel = defaultModel; + if (isActive !== undefined) updateData.isActive = isActive; + if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey; + if (testStatus !== undefined) updateData.testStatus = testStatus; + if (lastError !== undefined) updateData.lastError = lastError; + if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt; + if (lastErrorType !== undefined) updateData.lastErrorType = lastErrorType; + if (lastErrorSource !== undefined) updateData.lastErrorSource = lastErrorSource; + if (errorCode !== undefined) updateData.errorCode = errorCode; + if (rateLimitedUntil !== undefined) updateData.rateLimitedUntil = rateLimitedUntil; + if (lastTested !== undefined) updateData.lastTested = lastTested; + if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval; + + const updated = await updateProviderConnection(id, updateData); + + // Hide sensitive fields + const result = { ...updated }; + delete result.apiKey; + delete result.accessToken; + delete result.refreshToken; + delete result.idToken; + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ connection: result }); + } catch (error) { + console.log("Error updating connection:", error); + return NextResponse.json({ error: "Failed to update connection" }, { status: 500 }); + } +} + +// DELETE /api/providers/[id] - Delete connection +export async function DELETE(request, { params }) { + try { + const { id } = await params; + + const deleted = await deleteProviderConnection(id); + if (!deleted) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ message: "Connection deleted successfully" }); + } catch (error) { + console.log("Error deleting connection:", error); + return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing providers to cloud:", error); + } +} diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js new file mode 100644 index 0000000000..da9576d0e7 --- /dev/null +++ b/src/app/api/providers/[id]/test/route.js @@ -0,0 +1,579 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { validateProviderApiKey } from "@/lib/providers/validation"; +import { getCliRuntimeStatus } from "@/shared/services/cliRuntime"; +// Use the shared open-sse token refresh with built-in dedup/race-condition cache +import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.js"; + +// OAuth provider test endpoints +const OAUTH_TEST_CONFIG = { + claude: { + // Claude doesn't have userinfo, we verify token exists and not expired + checkExpiry: true, + }, + codex: { + // Codex OAuth tokens are ChatGPT session tokens, NOT standard OpenAI API keys. + // They don't work with api.openai.com/v1/models (returns 403 "Access denied"). + // Use checkExpiry mode instead — actual connectivity is validated via Usage/Limits. + checkExpiry: true, + refreshable: true, + }, + "gemini-cli": { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + antigravity: { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + github: { + url: "https://api.github.com/user", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, + }, + iflow: { + url: "https://iflow.cn/api/oauth/getUserInfo", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + qwen: { + url: "https://portal.qwen.ai/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + cursor: { + checkExpiry: true, + }, + "kimi-coding": { + checkExpiry: true, + refreshable: true, + }, + kilocode: { + // Kilo OAuth does not expose a stable user-info endpoint in all environments. + // Validate using token presence/expiry as a lightweight auth check. + checkExpiry: true, + }, + cline: { + url: "https://api.cline.bot/api/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + extraHeaders: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + }, + kiro: { + checkExpiry: true, + refreshable: true, + }, +}; + +const CLI_RUNTIME_PROVIDER_MAP = { + cline: "cline", + kilocode: "kilo", +}; + +function toSafeMessage(value, fallback = "Unknown error") { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed || fallback; +} + +function makeDiagnosis(type, source, message, code = null) { + return { + type, + source, + message: message || null, + code: code ?? null, + }; +} + +function classifyFailure({ error, statusCode = null, refreshFailed = false, unsupported = false }) { + const message = toSafeMessage(error, "Connection test failed"); + const normalized = message.toLowerCase(); + const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null; + + if (unsupported) { + return makeDiagnosis("unsupported", "validation", message, "unsupported"); + } + + if (refreshFailed || normalized.includes("refresh failed")) { + return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed"); + } + + if (numericStatus === 401 || numericStatus === 403) { + return makeDiagnosis("upstream_auth_error", "upstream", message, String(numericStatus)); + } + + if (numericStatus === 429) { + return makeDiagnosis("upstream_rate_limited", "upstream", message, "429"); + } + + if (numericStatus && numericStatus >= 500) { + return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus)); + } + + if (normalized.includes("token expired") || normalized.includes("expired")) { + return makeDiagnosis("token_expired", "oauth", message, "token_expired"); + } + + if ( + normalized.includes("invalid api key") || + normalized.includes("token invalid") || + normalized.includes("revoked") || + normalized.includes("access denied") || + normalized.includes("unauthorized") || + normalized.includes("forbidden") + ) { + return makeDiagnosis( + "upstream_auth_error", + "upstream", + message, + numericStatus ? String(numericStatus) : "auth_failed" + ); + } + + if ( + normalized.includes("rate limit") || + normalized.includes("quota") || + normalized.includes("too many requests") + ) { + return makeDiagnosis( + "upstream_rate_limited", + "upstream", + message, + numericStatus ? String(numericStatus) : "rate_limited" + ); + } + + if ( + normalized.includes("fetch failed") || + normalized.includes("network") || + normalized.includes("timeout") || + normalized.includes("econn") || + normalized.includes("enotfound") || + normalized.includes("socket") + ) { + return makeDiagnosis("network_error", "upstream", message, "network_error"); + } + + return makeDiagnosis( + "upstream_error", + "upstream", + message, + numericStatus ? String(numericStatus) : "upstream_error" + ); +} + +async function getProviderRuntimeStatus(provider) { + const toolId = CLI_RUNTIME_PROVIDER_MAP[provider]; + if (!toolId) return null; + + try { + const runtime = await getCliRuntimeStatus(toolId); + if (runtime.installed && runtime.runnable) { + return runtime; + } + + const runtimeMessage = runtime.installed + ? `Local CLI runtime is installed but not runnable (${runtime.reason || "healthcheck_failed"})` + : "Local CLI runtime is not installed"; + + return { + ...runtime, + diagnosis: makeDiagnosis( + "runtime_error", + "local", + runtimeMessage, + runtime.reason || "runtime_error" + ), + error: runtimeMessage, + }; + } catch (error) { + const runtimeMessage = `Failed to check local CLI runtime: ${error?.message || "runtime_check_failed"}`; + return { + installed: false, + runnable: false, + reason: "runtime_check_failed", + diagnosis: makeDiagnosis("runtime_error", "local", runtimeMessage, "runtime_check_failed"), + error: runtimeMessage, + }; + } +} + +/** + * Refresh OAuth token using the shared open-sse getAccessToken. + * This shares the in-flight promise cache with the SSE layer, + * preventing race conditions where two code paths attempt to + * refresh the same token concurrently. + * + * @returns {object} { accessToken, expiresIn, refreshToken } or null if failed + */ +async function refreshOAuthToken(connection) { + const { provider, refreshToken } = connection; + if (!refreshToken) return null; + + try { + // Kiro needs extra fields the generic function expects + const credentials = { + refreshToken, + providerSpecificData: connection.providerSpecificData || {}, + }; + + const result = await getAccessToken(provider, credentials, console); + return result; // { accessToken, expiresIn, refreshToken } or null + } catch (err) { + console.log(`Error refreshing ${provider} token:`, err.message); + return null; + } +} + +/** + * Check if token is expired or about to expire (within 5 minutes) + */ +function isTokenExpired(connection) { + const expiresAtValue = connection.expiresAt || connection.tokenExpiresAt; + if (!expiresAtValue) return false; + const expiresAt = new Date(expiresAtValue).getTime(); + const buffer = 5 * 60 * 1000; // 5 minutes + return expiresAt <= Date.now() + buffer; +} + +/** + * Sync to cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after token refresh:", error); + } +} + +/** + * Test OAuth connection by calling provider API + * Auto-refreshes token if expired + * @returns {{ valid: boolean, error: string|null, refreshed: boolean, newTokens: object|null }} + */ +async function testOAuthConnection(connection) { + const config = OAUTH_TEST_CONFIG[connection.provider]; + + if (!config) { + const error = "Provider test not supported"; + return { + valid: false, + error, + refreshed: false, + diagnosis: classifyFailure({ error, unsupported: true }), + }; + } + + // Check if token exists + if (!connection.accessToken) { + const error = "No access token"; + return { + valid: false, + error, + refreshed: false, + diagnosis: makeDiagnosis("auth_missing", "local", error, "missing_access_token"), + }; + } + + let accessToken = connection.accessToken; + let refreshed = false; + let newTokens = null; + + // Auto-refresh if token is expired and provider supports refresh + const tokenExpired = isTokenExpired(connection); + if (config.refreshable && tokenExpired && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + accessToken = tokens.accessToken; + refreshed = true; + newTokens = tokens; + } else { + // Refresh failed + const error = "Token expired and refresh failed"; + return { + valid: false, + error, + refreshed: false, + diagnosis: classifyFailure({ error, refreshFailed: true }), + }; + } + } + + // For providers that only check expiry (no test endpoint available) + if (config.checkExpiry) { + // If we already refreshed successfully, token is valid + if (refreshed) { + return { + valid: true, + error: null, + refreshed, + newTokens, + diagnosis: makeDiagnosis("ok", "oauth", null, null), + }; + } + // Check if token is expired (no refresh available) + if (tokenExpired) { + const error = "Token expired"; + return { + valid: false, + error, + refreshed: false, + diagnosis: classifyFailure({ error }), + }; + } + return { + valid: true, + error: null, + refreshed: false, + newTokens: null, + diagnosis: makeDiagnosis("ok", "local", null, null), + }; + } + + // Call test endpoint + try { + const headers = { + [config.authHeader]: `${config.authPrefix}${accessToken}`, + ...config.extraHeaders, + }; + + const res = await fetch(config.url, { + method: config.method, + headers, + }); + + if (res.ok) { + return { + valid: true, + error: null, + refreshed, + newTokens, + diagnosis: makeDiagnosis("ok", "upstream", null, null), + }; + } + + // If 401/403 and we haven't tried refresh yet, try refresh now + // (attempt refresh for ANY provider with a refreshToken, not just those marked refreshable) + if ( + (res.status === 401 || res.status === 403) && + !refreshed && + connection.refreshToken && + typeof connection.refreshToken === "string" + ) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + // Retry with new token + const retryRes = await fetch(config.url, { + method: config.method, + headers: { + [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, + ...config.extraHeaders, + }, + }); + + if (retryRes.ok) { + return { + valid: true, + error: null, + refreshed: true, + newTokens: tokens, + diagnosis: makeDiagnosis("ok", "upstream", null, null), + }; + } + + const error = `API returned ${retryRes.status}`; + return { + valid: false, + error, + refreshed: true, + statusCode: retryRes.status, + diagnosis: classifyFailure({ error, statusCode: retryRes.status }), + }; + } + const error = "Token invalid or revoked"; + return { + valid: false, + error, + refreshed: false, + statusCode: 401, + diagnosis: classifyFailure({ error, statusCode: 401, refreshFailed: true }), + }; + } + + const error = + res.status === 401 + ? "Token invalid or revoked" + : res.status === 403 + ? "Access denied" + : `API returned ${res.status}`; + + return { + valid: false, + error, + refreshed, + statusCode: res.status, + diagnosis: classifyFailure({ error, statusCode: res.status }), + }; + } catch (err) { + const error = toSafeMessage(err?.message, "Connection test failed"); + return { + valid: false, + error, + refreshed, + diagnosis: classifyFailure({ error }), + }; + } +} + +/** + * Test API key connection + */ +async function testApiKeyConnection(connection) { + if (!connection.apiKey) { + const error = "Missing API key"; + return { + valid: false, + error, + diagnosis: makeDiagnosis("auth_missing", "local", error, "missing_api_key"), + }; + } + + const result = await validateProviderApiKey({ + provider: connection.provider, + apiKey: connection.apiKey, + providerSpecificData: connection.providerSpecificData, + }); + + if (result.unsupported) { + const error = "Provider test not supported"; + return { + valid: false, + error, + diagnosis: classifyFailure({ error, unsupported: true }), + }; + } + + const error = result.valid ? null : result.error || "Invalid API key"; + const diagnosis = result.valid + ? makeDiagnosis("ok", "upstream", null, null) + : classifyFailure({ error }); + + return { + valid: !!result.valid, + error, + diagnosis, + }; +} + +// POST /api/providers/[id]/test - Test connection +export async function POST(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + let result; + const startTime = Date.now(); + const runtime = await getProviderRuntimeStatus(connection.provider); + + if (runtime?.diagnosis) { + result = { + valid: false, + error: runtime.error, + refreshed: false, + diagnosis: runtime.diagnosis, + }; + } else if (connection.authType === "apikey") { + result = await testApiKeyConnection(connection); + } else { + result = await testOAuthConnection(connection); + } + + const latencyMs = Date.now() - startTime; + + // Build update data + const now = new Date().toISOString(); + const diagnosis = + result.diagnosis || + (result.valid + ? makeDiagnosis("ok", "local", null, null) + : classifyFailure({ error: result.error, statusCode: result.statusCode })); + + const updateData = { + testStatus: result.valid ? "active" : "error", + lastError: result.valid ? null : result.error, + lastErrorAt: result.valid ? null : now, + lastTested: now, + lastErrorType: result.valid ? null : diagnosis.type, + lastErrorSource: result.valid ? null : diagnosis.source, + errorCode: result.valid ? null : diagnosis.code || result.statusCode || null, + rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null, + }; + + if (result.valid) { + updateData.backoffLevel = 0; + } + + // If token was refreshed, update tokens in DB + if (result.refreshed && result.newTokens) { + updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.refreshToken) { + updateData.refreshToken = result.newTokens.refreshToken; + } + if (result.newTokens.expiresIn) { + updateData.expiresAt = new Date( + Date.now() + result.newTokens.expiresIn * 1000 + ).toISOString(); + } + } + + // Update status in db + await updateProviderConnection(id, updateData); + + // Sync to cloud if token was refreshed + if (result.refreshed) { + await syncToCloudIfEnabled(); + } + + return NextResponse.json({ + valid: result.valid, + error: result.error, + refreshed: result.refreshed || false, + diagnosis, + latencyMs, + statusCode: result.statusCode || null, + runtime: runtime || null, + testedAt: now, + }); + } catch (error) { + console.log("Error testing connection:", error); + return NextResponse.json({ error: "Test failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js new file mode 100644 index 0000000000..84875c2808 --- /dev/null +++ b/src/app/api/providers/client/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections } from "@/lib/localDb"; + +// GET /api/providers/client - List all connections for client (includes sensitive fields for sync) +export async function GET() { + try { + const connections = await getProviderConnections(); + + // Include sensitive fields for sync to cloud (only accessible from same origin) + const clientConnections = connections.map((c) => ({ + ...c, + // Don't hide sensitive fields here since this is for internal sync + })); + + return NextResponse.json({ connections: clientConnections }); + } catch (error) { + console.log("Error fetching providers for client:", error); + return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js new file mode 100644 index 0000000000..7d260d9574 --- /dev/null +++ b/src/app/api/providers/route.js @@ -0,0 +1,146 @@ +import { NextResponse } from "next/server"; +import { + getProviderConnections, + createProviderConnection, + getProviderNodeById, + isCloudEnabled, +} from "@/models"; +import { APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, +} from "@/shared/constants/providers"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { createProviderSchema, validateBody } from "@/shared/validation/schemas"; + +// GET /api/providers - List all connections +export async function GET() { + try { + const connections = await getProviderConnections(); + + // Hide sensitive fields + const safeConnections = connections.map((c) => ({ + ...c, + apiKey: undefined, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + })); + + return NextResponse.json({ connections: safeConnections }); + } catch (error) { + console.log("Error fetching providers:", error); + return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); + } +} + +// POST /api/providers - Create new connection (API Key only, OAuth via separate flow) +export async function POST(request) { + try { + const body = await request.json(); + + // Zod validation + const validation = validateBody(createProviderSchema, body); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = + validation.data; + + // Business validation + const isValidProvider = + APIKEY_PROVIDERS[provider] || + isOpenAICompatibleProvider(provider) || + isAnthropicCompatibleProvider(provider); + + if (!isValidProvider) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + let providerSpecificData = null; + const allowMultipleCompatibleConnections = + process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true"; + + if (isOpenAICompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); + } + + const existingConnections = await getProviderConnections({ provider }); + if (!allowMultipleCompatibleConnections && existingConnections.length > 0) { + return NextResponse.json( + { error: "Only one connection is allowed for this OpenAI Compatible node" }, + { status: 400 } + ); + } + + providerSpecificData = { + prefix: node.prefix, + apiType: node.apiType, + baseUrl: node.baseUrl, + nodeName: node.name, + }; + } else if (isAnthropicCompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 }); + } + + const existingConnections = await getProviderConnections({ provider }); + if (!allowMultipleCompatibleConnections && existingConnections.length > 0) { + return NextResponse.json( + { error: "Only one connection is allowed for this Anthropic Compatible node" }, + { status: 400 } + ); + } + + providerSpecificData = { + prefix: node.prefix, + baseUrl: node.baseUrl, + nodeName: node.name, + }; + } + + const newConnection = await createProviderConnection({ + provider, + authType: "apikey", + name, + apiKey, + priority: priority || 1, + globalPriority: globalPriority || null, + defaultModel: defaultModel || null, + providerSpecificData, + isActive: true, + testStatus: testStatus || "unknown", + }); + + // Hide sensitive fields + const result = { ...newConnection }; + delete result.apiKey; + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ connection: result }, { status: 201 }); + } catch (error) { + console.log("Error creating provider:", error); + return NextResponse.json({ error: "Failed to create provider" }, { status: 500 }); + } +} + +/** + * Sync to Cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing providers to cloud:", error); + } +} diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js new file mode 100644 index 0000000000..06e0d301ca --- /dev/null +++ b/src/app/api/providers/test-batch/route.js @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections } from "@/models"; +import { + FREE_PROVIDERS, + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; + +// Determine auth type group for a provider id +function getAuthGroup(providerId) { + if (FREE_PROVIDERS[providerId]) return "free"; + if (OAUTH_PROVIDERS[providerId]) return "oauth"; + if (APIKEY_PROVIDERS[providerId]) return "apikey"; + if ( + typeof providerId === "string" && + (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || + providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + ) + return "compatible"; + return "apikey"; +} + +function isCompatibleProvider(providerId) { + return ( + typeof providerId === "string" && + (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || + providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + ); +} + +// POST /api/providers/test-batch - Test multiple connections by group +export async function POST(request) { + try { + const body = await request.json(); + const { mode, providerId } = body; + + if (!mode) { + return NextResponse.json({ error: "mode is required" }, { status: 400 }); + } + + // Fetch all active connections + const allConnections = await getProviderConnections({ isActive: true }); + + // Filter based on mode + let connectionsToTest = []; + if (mode === "provider" && providerId) { + connectionsToTest = allConnections.filter((c) => c.provider === providerId); + } else if (mode === "oauth") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "oauth"); + } else if (mode === "free") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "free"); + } else if (mode === "apikey") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider) === "apikey"); + } else if (mode === "compatible") { + connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider)); + } else if (mode === "all") { + connectionsToTest = allConnections; + } else { + return NextResponse.json( + { error: "Invalid mode. Use: provider, oauth, free, apikey, compatible, all" }, + { status: 400 } + ); + } + + if (connectionsToTest.length === 0) { + return NextResponse.json({ + mode, + providerId: providerId || null, + results: [], + testedAt: new Date().toISOString(), + }); + } + + // Test each connection sequentially via internal API call + const results = []; + const baseUrl = request.nextUrl.origin; + + for (const conn of connectionsToTest) { + const startTime = Date.now(); + try { + const res = await fetch(`${baseUrl}/api/providers/${conn.id}/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(20000), + }); + const data = await res.json(); + const latencyMs = data.latencyMs || Date.now() - startTime; + + results.push({ + provider: conn.provider, + connectionId: conn.id, + connectionName: conn.name || conn.email || conn.provider, + authType: conn.authType || getAuthGroup(conn.provider), + valid: data.valid, + latencyMs, + error: data.error || null, + diagnosis: data.diagnosis || null, + statusCode: data.statusCode || null, + testedAt: data.testedAt || new Date().toISOString(), + }); + } catch (error) { + const latencyMs = Date.now() - startTime; + results.push({ + provider: conn.provider, + connectionId: conn.id, + connectionName: conn.name || conn.email || conn.provider, + authType: conn.authType || getAuthGroup(conn.provider), + valid: false, + latencyMs, + error: error.name === "TimeoutError" ? "Timeout (20s)" : error.message, + diagnosis: { type: "network_error", source: "local", code: null, message: error.message }, + statusCode: null, + testedAt: new Date().toISOString(), + }); + } + } + + return NextResponse.json({ + mode, + providerId: providerId || null, + results, + testedAt: new Date().toISOString(), + summary: { + total: results.length, + passed: results.filter((r) => r.valid).length, + failed: results.filter((r) => !r.valid).length, + }, + }); + } catch (error) { + console.log("Error in batch test:", error); + return NextResponse.json({ error: "Batch test failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js new file mode 100644 index 0000000000..74c15cd3f6 --- /dev/null +++ b/src/app/api/providers/validate/route.js @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { getProviderNodeById } from "@/models"; +import { + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, +} from "@/shared/constants/providers"; +import { validateProviderApiKey } from "@/lib/providers/validation"; + +// POST /api/providers/validate - Validate API key with provider +export async function POST(request) { + try { + const body = await request.json(); + const { provider, apiKey } = body; + + if (!provider || !apiKey) { + return NextResponse.json({ error: "Provider and API key required" }, { status: 400 }); + } + + let providerSpecificData = {}; + + if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + const typeName = isOpenAICompatibleProvider(provider) ? "OpenAI" : "Anthropic"; + return NextResponse.json( + { error: `${typeName} Compatible node not found` }, + { status: 404 } + ); + } + providerSpecificData = { + baseUrl: node.baseUrl, + apiType: node.apiType, + }; + } + + const result = await validateProviderApiKey({ + provider, + apiKey, + providerSpecificData, + }); + + if (result.unsupported) { + return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); + } + + return NextResponse.json({ + valid: !!result.valid, + error: result.valid ? null : result.error || "Invalid API key", + }); + } catch (error) { + console.log("Error validating API key:", error); + return NextResponse.json({ error: "Validation failed" }, { status: 500 }); + } +} diff --git a/src/app/api/rate-limit/route.js b/src/app/api/rate-limit/route.js new file mode 100644 index 0000000000..e4f247b8f1 --- /dev/null +++ b/src/app/api/rate-limit/route.js @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { + enableRateLimitProtection, + disableRateLimitProtection, + getRateLimitStatus, + getAllRateLimitStatus, +} from "@omniroute/open-sse/services/rateLimitManager.js"; + +/** + * GET /api/rate-limit — Get rate limit status for all connections + */ +export async function GET() { + try { + const connections = await getProviderConnections(); + const statuses = connections.map((conn) => ({ + connectionId: conn.id, + provider: conn.provider, + name: conn.name || conn.email || conn.id.slice(0, 8), + rateLimitProtection: !!conn.rateLimitProtection, + ...getRateLimitStatus(conn.provider, conn.id), + })); + + return NextResponse.json({ + connections: statuses, + overview: getAllRateLimitStatus(), + }); + } catch (error) { + console.error("[API ERROR] /api/rate-limit GET:", error); + return NextResponse.json({ error: "Failed to get rate limit status" }, { status: 500 }); + } +} + +/** + * POST /api/rate-limit — Toggle rate limit protection for a connection + * Body: { connectionId: string, enabled: boolean } + */ +export async function POST(request) { + try { + const { connectionId, enabled } = await request.json(); + + if (!connectionId) { + return NextResponse.json({ error: "Missing connectionId" }, { status: 400 }); + } + + // Update in-memory state + if (enabled) { + enableRateLimitProtection(connectionId); + } else { + disableRateLimitProtection(connectionId); + } + + // Persist to database + await updateProviderConnection(connectionId, { + rateLimitProtection: !!enabled, + }); + + return NextResponse.json({ success: true, connectionId, enabled: !!enabled }); + } catch (error) { + console.error("[API ERROR] /api/rate-limit POST:", error); + return NextResponse.json({ error: "Failed to toggle rate limit" }, { status: 500 }); + } +} diff --git a/src/app/api/restart/route.js b/src/app/api/restart/route.js new file mode 100644 index 0000000000..909ccf44dd --- /dev/null +++ b/src/app/api/restart/route.js @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + // Graceful restart: exit with code 0 so the process manager (pm2/systemd) restarts + setTimeout(() => { + process.exit(0); + }, 500); + + return NextResponse.json({ status: "restarting" }); +} diff --git a/src/app/api/settings/combo-defaults/route.js b/src/app/api/settings/combo-defaults/route.js new file mode 100644 index 0000000000..264a4528f1 --- /dev/null +++ b/src/app/api/settings/combo-defaults/route.js @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { getSettings, updateSettings } from "@/lib/localDb"; + +/** + * GET /api/settings/combo-defaults + * Returns the current combo global defaults and provider overrides + */ +export async function GET() { + try { + const settings = await getSettings(); + return NextResponse.json({ + comboDefaults: settings.comboDefaults || { + strategy: "priority", + maxRetries: 1, + retryDelayMs: 2000, + timeoutMs: 120000, + healthCheckEnabled: true, + healthCheckTimeoutMs: 3000, + maxComboDepth: 3, + trackMetrics: true, + }, + providerOverrides: settings.providerOverrides || {}, + }); + } catch (error) { + console.log("Error fetching combo defaults:", error); + return NextResponse.json({ error: "Failed to fetch combo defaults" }, { status: 500 }); + } +} + +/** + * PATCH /api/settings/combo-defaults + * Update combo global defaults and/or provider overrides + * Body: { comboDefaults?: {...}, providerOverrides?: {...} } + */ +export async function PATCH(request) { + try { + const body = await request.json(); + const updates = {}; + + if (body.comboDefaults) { + updates.comboDefaults = body.comboDefaults; + } + if (body.providerOverrides) { + updates.providerOverrides = body.providerOverrides; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); + } + + const settings = await updateSettings(updates); + return NextResponse.json({ + comboDefaults: settings.comboDefaults || {}, + providerOverrides: settings.providerOverrides || {}, + }); + } catch (error) { + console.log("Error updating combo defaults:", error); + return NextResponse.json({ error: "Failed to update combo defaults" }, { status: 500 }); + } +} diff --git a/src/app/api/settings/proxy/route.js b/src/app/api/settings/proxy/route.js new file mode 100644 index 0000000000..5e98edd4c9 --- /dev/null +++ b/src/app/api/settings/proxy/route.js @@ -0,0 +1,165 @@ +import { + getProxyConfig, + setProxyConfig, + getProxyForLevel, + deleteProxyForLevel, + resolveProxyForConnection, +} from "../../../../lib/localDb.js"; + +const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); + +function isSocks5Enabled() { + return process.env.ENABLE_SOCKS5_PROXY === "true"; +} + +function getSupportedProxyTypes() { + if (isSocks5Enabled()) { + return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]); + } + return BASE_SUPPORTED_PROXY_TYPES; +} + +function supportedTypesMessage() { + return isSocks5Enabled() ? "http, https, or socks5" : "http or https"; +} + +function createInvalidProxyError(message) { + const error = new Error(message); + error.status = 400; + error.type = "invalid_request"; + return error; +} + +function normalizeAndValidateProxy(proxy, pathLabel) { + if (proxy === null || proxy === undefined) return proxy; + if (typeof proxy !== "object" || Array.isArray(proxy)) { + throw createInvalidProxyError(`${pathLabel} must be an object`); + } + + const type = String(proxy.type || "http").toLowerCase(); + if (type === "socks5" && !isSocks5Enabled()) { + throw createInvalidProxyError( + "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)" + ); + } + if (type.startsWith("socks") && type !== "socks5") { + throw createInvalidProxyError(`${pathLabel}.type must be ${supportedTypesMessage()}`); + } + if (!getSupportedProxyTypes().has(type)) { + throw createInvalidProxyError(`${pathLabel}.type must be ${supportedTypesMessage()}`); + } + + return { ...proxy, type }; +} + +function normalizeAndValidateProxyMap(proxyMap, mapName) { + if (proxyMap === undefined) return undefined; + if (proxyMap === null || typeof proxyMap !== "object" || Array.isArray(proxyMap)) { + throw createInvalidProxyError(`${mapName} must be an object`); + } + + const normalizedMap = { ...proxyMap }; + for (const [id, proxy] of Object.entries(proxyMap)) { + normalizedMap[id] = normalizeAndValidateProxy(proxy, `${mapName}.${id}`); + } + return normalizedMap; +} + +function normalizeProxyPayload(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw createInvalidProxyError("Request body must be an object"); + } + + const normalized = { ...body }; + if (Object.prototype.hasOwnProperty.call(body, "proxy")) { + normalized.proxy = normalizeAndValidateProxy(body.proxy, "proxy"); + } + if (Object.prototype.hasOwnProperty.call(body, "global")) { + normalized.global = normalizeAndValidateProxy(body.global, "global"); + } + for (const key of ["providers", "combos", "keys"]) { + if (Object.prototype.hasOwnProperty.call(body, key)) { + normalized[key] = normalizeAndValidateProxyMap(body[key], key); + } + } + return normalized; +} + +/** + * GET /api/settings/proxy — get proxy configuration + * Optional query params: ?level=global|provider|combo|key&id=xxx + * Or: ?resolve=connectionId to resolve effective proxy + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const level = searchParams.get("level"); + const id = searchParams.get("id"); + const resolveId = searchParams.get("resolve"); + + // Resolve effective proxy for a connection + if (resolveId) { + const result = await resolveProxyForConnection(resolveId); + return Response.json(result); + } + + // Get proxy for a specific level + if (level) { + const proxy = await getProxyForLevel(level, id); + return Response.json({ level, id, proxy }); + } + + // Get full config + const config = await getProxyConfig(); + return Response.json(config); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} + +/** + * PUT /api/settings/proxy — update proxy configuration + * Body: { level, id?, proxy } or legacy { global?, providers? } + */ +export async function PUT(request) { + try { + const body = await request.json(); + const normalizedBody = normalizeProxyPayload(body); + const updated = await setProxyConfig(normalizedBody); + return Response.json(updated); + } catch (error) { + const status = Number(error?.status) || 500; + const type = error?.type || (status === 400 ? "invalid_request" : "server_error"); + return Response.json({ error: { message: error.message, type } }, { status }); + } +} + +/** + * DELETE /api/settings/proxy — remove proxy at a level + * Query: ?level=provider&id=xxx + */ +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const level = searchParams.get("level"); + const id = searchParams.get("id"); + + if (!level) { + return Response.json( + { error: { message: "level is required", type: "invalid_request" } }, + { status: 400 } + ); + } + + const updated = await deleteProxyForLevel(level, id); + return Response.json(updated); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/proxy/test/route.js b/src/app/api/settings/proxy/test/route.js new file mode 100644 index 0000000000..3989b5f2a6 --- /dev/null +++ b/src/app/api/settings/proxy/test/route.js @@ -0,0 +1,146 @@ +import { request as undiciRequest } from "undici"; +import { + createProxyDispatcher, + isSocks5ProxyEnabled, + proxyConfigToUrl, + proxyUrlForLogs, +} from "@omniroute/open-sse/utils/proxyDispatcher.js"; + +const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); + +function getSupportedProxyTypes() { + if (isSocks5ProxyEnabled()) { + return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]); + } + return BASE_SUPPORTED_PROXY_TYPES; +} + +function supportedTypesMessage() { + return isSocks5ProxyEnabled() ? "http, https, or socks5" : "http or https"; +} + +/** + * POST /api/settings/proxy/test — test proxy connectivity + * Body: { proxy: { type, host, port, username?, password? } } + * Returns: { success, publicIp?, latencyMs?, error? } + */ +export async function POST(request) { + try { + const { proxy } = await request.json(); + + if (!proxy || !proxy.host || !proxy.port) { + return Response.json( + { error: { message: "proxy.host and proxy.port are required", type: "invalid_request" } }, + { status: 400 } + ); + } + + const proxyType = String(proxy.type || "http").toLowerCase(); + if (proxyType === "socks5" && !isSocks5ProxyEnabled()) { + return Response.json( + { + error: { + message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)", + type: "invalid_request", + }, + }, + { status: 400 } + ); + } + if (proxyType.startsWith("socks") && proxyType !== "socks5") { + return Response.json( + { + error: { + message: `proxy.type must be ${supportedTypesMessage()}`, + type: "invalid_request", + }, + }, + { status: 400 } + ); + } + if (!getSupportedProxyTypes().has(proxyType)) { + return Response.json( + { + error: { + message: `proxy.type must be ${supportedTypesMessage()}`, + type: "invalid_request", + }, + }, + { status: 400 } + ); + } + + let proxyUrl; + try { + proxyUrl = proxyConfigToUrl( + { + type: proxyType, + host: proxy.host, + port: proxy.port, + username: proxy.username || "", + password: proxy.password || "", + }, + { allowSocks5: isSocks5ProxyEnabled() } + ); + } catch (proxyError) { + return Response.json( + { + error: { + message: proxyError.message || "Invalid proxy configuration", + type: "invalid_request", + }, + }, + { status: 400 } + ); + } + + const publicProxyUrl = proxyUrlForLogs(proxyUrl); + + const startTime = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const dispatcher = createProxyDispatcher(proxyUrl); + + try { + const result = await undiciRequest("https://api.ipify.org?format=json", { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: 10000, + bodyTimeout: 10000, + }); + + const rawBody = await result.body.text(); + let parsed; + try { + parsed = JSON.parse(rawBody); + } catch { + parsed = { ip: rawBody.trim() }; + } + + return Response.json({ + success: true, + publicIp: parsed.ip || null, + latencyMs: Date.now() - startTime, + proxyUrl: publicProxyUrl, + }); + } catch (fetchError) { + return Response.json({ + success: false, + error: + fetchError.name === "AbortError" + ? "Connection timeout (10s)" + : fetchError.message || "Connection failed", + latencyMs: Date.now() - startTime, + proxyUrl: publicProxyUrl, + }); + } finally { + clearTimeout(timeout); + } + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/require-login/route.js b/src/app/api/settings/require-login/route.js new file mode 100644 index 0000000000..1e1a14d680 --- /dev/null +++ b/src/app/api/settings/require-login/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; + +export async function GET() { + try { + const settings = await getSettings(); + const requireLogin = settings.requireLogin !== false; + return NextResponse.json({ requireLogin }); + } catch (error) { + return NextResponse.json({ requireLogin: true }, { status: 200 }); + } +} diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js new file mode 100644 index 0000000000..13aff77650 --- /dev/null +++ b/src/app/api/settings/route.js @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import bcrypt from "bcryptjs"; +import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas"; + +export async function GET() { + try { + const settings = await getSettings(); + const { password, ...safeSettings } = settings; + + const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true"; + + return NextResponse.json({ + ...safeSettings, + enableRequestLogs, + hasPassword: !!password, + }); + } catch (error) { + console.log("Error getting settings:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function PATCH(request) { + try { + const rawBody = await request.json(); + + // Zod validation + const validation = validateBody(updateSettingsSchema, rawBody); + if (!validation.success) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const body = validation.data; + + // If updating password, hash it + if (body.newPassword) { + const settings = await getSettings(); + const currentHash = settings.password; + + // Verify current password if it exists + if (currentHash) { + if (!body.currentPassword) { + return NextResponse.json({ error: "Current password required" }, { status: 400 }); + } + const isValid = await bcrypt.compare(body.currentPassword, currentHash); + if (!isValid) { + return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); + } + } else { + // First time setting password, no current password needed + // Allow empty currentPassword or default "123456" + if (body.currentPassword && body.currentPassword !== "123456") { + return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); + } + } + + const salt = await bcrypt.genSalt(10); + body.password = await bcrypt.hash(body.newPassword, salt); + delete body.newPassword; + delete body.currentPassword; + } + + const settings = await updateSettings(body); + const { password, ...safeSettings } = settings; + return NextResponse.json(safeSettings); + } catch (error) { + console.log("Error updating settings:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/shutdown/route.js b/src/app/api/shutdown/route.js new file mode 100644 index 0000000000..ce7134011c --- /dev/null +++ b/src/app/api/shutdown/route.js @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const response = NextResponse.json({ success: true, message: "Shutting down..." }); + + setTimeout(() => { + process.exit(0); + }, 500); + + return response; +} diff --git a/src/app/api/storage/health/route.js b/src/app/api/storage/health/route.js new file mode 100644 index 0000000000..76ff26a40f --- /dev/null +++ b/src/app/api/storage/health/route.js @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs"; +import { resolveDataDir } from "@/lib/dataPaths.js"; + +/** + * GET /api/storage/health — Return database storage information. + * Provides: driver, dbPath, sizeBytes, lastBackupAt, retentionDays + */ +export async function GET() { + try { + const dataDir = resolveDataDir({}); + const dbFilePath = path.join(dataDir, "storage.sqlite"); + const backupsDir = path.join(dataDir, "db_backups"); + + // Get DB file size + let sizeBytes = 0; + try { + if (fs.existsSync(dbFilePath)) { + const stat = fs.statSync(dbFilePath); + sizeBytes = stat.size; + } + } catch { + /* ignore */ + } + + // Get last backup info + let lastBackupAt = null; + let backupCount = 0; + try { + if (fs.existsSync(backupsDir)) { + const files = fs + .readdirSync(backupsDir) + .filter((f) => f.startsWith("db_") && f.endsWith(".sqlite")) + .sort() + .reverse(); + backupCount = files.length; + if (files.length > 0) { + const latestStat = fs.statSync(path.join(backupsDir, files[0])); + lastBackupAt = latestStat.mtime.toISOString(); + } + } + } catch { + /* ignore */ + } + + // Get the display path (abbreviated with ~) + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const displayPath = dbFilePath.startsWith(homeDir) + ? "~" + dbFilePath.slice(homeDir.length) + : dbFilePath; + + return NextResponse.json({ + driver: "sqlite", + dbPath: displayPath, + sizeBytes, + lastBackupAt, + backupCount, + retentionDays: 90, + dataDir: dataDir.startsWith(homeDir) ? "~" + dataDir.slice(homeDir.length) : dataDir, + }); + } catch (error) { + console.error("[API] Error getting storage health:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/sync/cloud/route.js b/src/app/api/sync/cloud/route.js new file mode 100644 index 0000000000..aa5a32c354 --- /dev/null +++ b/src/app/api/sync/cloud/route.js @@ -0,0 +1,173 @@ +import { NextResponse } from "next/server"; +import { getApiKeys, createApiKey, updateSettings } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud, fetchWithTimeout, CLOUD_URL } from "@/lib/cloudSync"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +/** + * POST /api/sync/cloud + * Sync data with Cloud + */ +export async function POST(request) { + try { + const body = await request.json(); + const { action } = body; + + // Always get machineId from server, don't trust client + const machineId = await getConsistentMachineId(); + + switch (action) { + case "enable": + await updateSettings({ cloudEnabled: true }); + // Auto create key if none exists + const keys = await getApiKeys(); + let createdKey = null; + if (keys.length === 0) { + createdKey = await createApiKey("Default Key", machineId); + } + return syncAndVerify(machineId, createdKey?.key, keys); + case "sync": { + const syncResult = await syncToCloud(machineId); + if (syncResult.error) { + return NextResponse.json(syncResult, { status: 502 }); + } + return NextResponse.json(syncResult); + } + case "disable": + await updateSettings({ cloudEnabled: false }); + return handleDisable(machineId, request); + default: + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } + } catch (error) { + console.log("Cloud sync error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync and verify connection with ping + */ +async function syncAndVerify(machineId, createdKey, existingKeys) { + // Step 1: Sync data to cloud + const syncResult = await syncToCloud(machineId, createdKey); + if (syncResult.error) { + return NextResponse.json(syncResult, { status: 502 }); + } + + // Step 2: Verify connection by pinging the cloud + const apiKey = createdKey || existingKeys[0]?.key; + if (!apiKey) { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: "No API key available", + }); + } + + try { + const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }); + + if (pingResponse.ok) { + return NextResponse.json({ + ...syncResult, + verified: true, + }); + } else { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: `Ping failed: ${pingResponse.status}`, + }); + } + } catch (error) { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: error.message, + }); + } +} + +/** + * Disable Cloud - delete cache and update Claude CLI settings + */ +async function handleDisable(machineId, request) { + if (!CLOUD_URL) { + return NextResponse.json({ error: "NEXT_PUBLIC_CLOUD_URL is not configured" }, { status: 500 }); + } + + let response; + try { + response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, { + method: "DELETE", + }); + } catch (error) { + const isTimeout = error?.name === "AbortError"; + return NextResponse.json( + { + error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service", + }, + { status: 502 } + ); + } + + if (!response.ok) { + const errorText = await response.text(); + console.log("Cloud disable failed:", errorText); + return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 }); + } + + // Update Claude CLI settings to use local endpoint + const host = request.headers.get("host") || "localhost:20128"; + await updateClaudeSettingsToLocal(machineId, host); + + return NextResponse.json({ + success: true, + message: "Cloud disabled", + }); +} + +/** + * Update Claude CLI settings to use local endpoint (only if currently using cloud) + */ +async function updateClaudeSettingsToLocal(machineId, host) { + try { + const settingsPath = path.join(os.homedir(), ".claude", "settings.json"); + const cloudUrl = `${CLOUD_URL}/${machineId}`; + const localUrl = `http://${host}`; + + // Read current settings + let settings; + try { + const content = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") { + return; // No settings file, nothing to update + } + throw error; + } + + // Check if ANTHROPIC_BASE_URL matches cloud URL + const currentUrl = settings.env?.ANTHROPIC_BASE_URL; + if (!currentUrl || currentUrl !== cloudUrl) { + return; // Not using cloud URL, don't modify + } + + // Update to local URL + settings.env.ANTHROPIC_BASE_URL = localUrl; + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`Updated Claude CLI settings: ${cloudUrl} → ${localUrl}`); + } catch (error) { + console.log("Failed to update Claude CLI settings:", error.message); + } +} diff --git a/src/app/api/sync/initialize/route.js b/src/app/api/sync/initialize/route.js new file mode 100644 index 0000000000..9590c2e78c --- /dev/null +++ b/src/app/api/sync/initialize/route.js @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import initializeCloudSync from "@/shared/services/initializeCloudSync"; + +let syncInitialized = false; + +// POST /api/sync/initialize - Initialize cloud sync scheduler +export async function POST(request) { + try { + if (syncInitialized) { + return NextResponse.json({ + message: "Cloud sync already initialized", + }); + } + + await initializeCloudSync(); + syncInitialized = true; + + return NextResponse.json({ + success: true, + message: "Cloud sync initialized successfully", + }); + } catch (error) { + console.log("Error initializing cloud sync:", error); + return NextResponse.json( + { + error: "Failed to initialize cloud sync", + }, + { status: 500 } + ); + } +} + +// GET /api/sync/status - Check sync initialization status +export async function GET(request) { + return NextResponse.json({ + initialized: syncInitialized, + message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized", + }); +} diff --git a/src/app/api/tags/route.js b/src/app/api/tags/route.js new file mode 100644 index 0000000000..2c931b6d87 --- /dev/null +++ b/src/app/api/tags/route.js @@ -0,0 +1,17 @@ +import { ollamaModels } from "@omniroute/open-sse/config/ollamaModels.js"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function GET() { + return new Response(JSON.stringify(ollamaModels), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); +} diff --git a/src/app/api/translator/detect/route.js b/src/app/api/translator/detect/route.js new file mode 100644 index 0000000000..47b79bd515 --- /dev/null +++ b/src/app/api/translator/detect/route.js @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { detectFormat } from "@omniroute/open-sse/services/provider.js"; + +/** + * POST /api/translator/detect + * Detect the format of a request body. + * Body: { body: object } + * Returns: { format, label } + */ +export async function POST(request) { + try { + const { body } = await request.json(); + + if (!body || typeof body !== "object") { + return NextResponse.json( + { success: false, error: "Body must be a JSON object" }, + { status: 400 } + ); + } + + const format = detectFormat(body); + + return NextResponse.json({ + success: true, + format, + }); + } catch (error) { + console.error("Error detecting format:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/translator/history/route.js b/src/app/api/translator/history/route.js new file mode 100644 index 0000000000..0783f153b8 --- /dev/null +++ b/src/app/api/translator/history/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getTranslationEvents } from "@/lib/translatorEvents.js"; + +/** + * GET /api/translator/history + * Returns recent translation events for the Live Monitor. + */ + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const limit = searchParams.get("limit"); + const { events, total } = getTranslationEvents(limit); + + return NextResponse.json({ success: true, events, total }); + } catch (error) { + console.error("Error fetching history:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/translator/load/route.js b/src/app/api/translator/load/route.js new file mode 100644 index 0000000000..e5ccb645d3 --- /dev/null +++ b/src/app/api/translator/load/route.js @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const file = searchParams.get("file"); + + if (!file) { + return NextResponse.json( + { success: false, error: "File parameter required" }, + { status: 400 } + ); + } + + // Security: only allow specific filenames + const allowedFiles = [ + "1_req_client.json", + "2_req_source.json", + "3_req_openai.json", + "4_req_target.json", + "5_res_provider.txt", + ]; + + if (!allowedFiles.includes(file)) { + return NextResponse.json({ success: false, error: "Invalid file name" }, { status: 400 }); + } + + const logsDir = path.join(process.cwd(), "logs", "translator"); + const filePath = path.join(logsDir, file); + + // Check if file exists + if (!fs.existsSync(filePath)) { + return NextResponse.json({ success: false, error: "File not found" }, { status: 404 }); + } + + const content = fs.readFileSync(filePath, "utf-8"); + + return NextResponse.json({ success: true, content }); + } catch (error) { + console.error("Error loading file:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/translator/save/route.js b/src/app/api/translator/save/route.js new file mode 100644 index 0000000000..72cb956fe4 --- /dev/null +++ b/src/app/api/translator/save/route.js @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +export async function POST(request) { + try { + const { file, content } = await request.json(); + + if (!file || content === undefined) { + return NextResponse.json( + { success: false, error: "File and content required" }, + { status: 400 } + ); + } + + // Security: only allow specific filenames + const allowedFiles = [ + "1_req_client.json", + "2_req_source.json", + "3_req_openai.json", + "4_req_target.json", + "5_res_provider.txt", + ]; + + if (!allowedFiles.includes(file)) { + return NextResponse.json({ success: false, error: "Invalid file name" }, { status: 400 }); + } + + const logsDir = path.join(process.cwd(), "logs", "translator"); + + // Create directory if not exists + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const filePath = path.join(logsDir, file); + fs.writeFileSync(filePath, content, "utf-8"); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error saving file:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/translator/send/route.js b/src/app/api/translator/send/route.js new file mode 100644 index 0000000000..d54da691f7 --- /dev/null +++ b/src/app/api/translator/send/route.js @@ -0,0 +1,125 @@ +import { NextResponse } from "next/server"; +import { + buildProviderUrl, + buildProviderHeaders, + detectFormat, + getTargetFormat, +} from "@omniroute/open-sse/services/provider.js"; +import { getProviderConnections } from "@/lib/localDb.js"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { logTranslationEvent } from "@/lib/translatorEvents.js"; + +export async function POST(request) { + try { + const startedAt = Date.now(); + const { provider, body } = await request.json(); + + if (!provider || !body) { + return NextResponse.json( + { success: false, error: "Provider and body required" }, + { status: 400 } + ); + } + + const sourceFormat = detectFormat(body); + const targetFormat = getTargetFormat(provider); + + // Get provider credentials from database + const connections = await getProviderConnections({ provider }); + const connection = connections.find((c) => c.isActive !== false); + + if (!connection) { + logTranslationEvent({ + provider, + model: body.model || "test-model", + sourceFormat, + targetFormat, + status: "error", + statusCode: 400, + latency: Date.now() - startedAt, + endpoint: "/api/translator/send", + }); + return NextResponse.json( + { + success: false, + error: `No active connection found for provider: ${provider}. Available connections: ${connections.length}`, + }, + { status: 400 } + ); + } + + const credentials = { + apiKey: connection.apiKey, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + copilotToken: connection.copilotToken, + projectId: connection.projectId, + providerSpecificData: connection.providerSpecificData, + }; + + // Build URL and headers using provider service + const url = buildProviderUrl(provider, body.model || "test-model", true, { + baseUrlIndex: 0, + baseUrl: connection.providerSpecificData?.baseUrl, + }); + const headers = buildProviderHeaders(provider, credentials, true, body); + + // Send request to provider + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + const normalizedUpstreamError = toJsonErrorPayload( + errorText, + `Provider error: ${response.status} ${response.statusText}` + ); + logTranslationEvent({ + provider, + model: body.model || "test-model", + sourceFormat, + targetFormat, + status: "error", + statusCode: response.status, + latency: Date.now() - startedAt, + endpoint: "/api/translator/send", + }); + return NextResponse.json( + { + success: false, + error: + normalizedUpstreamError.error?.message || + `Provider error: ${response.status} ${response.statusText}`, + details: normalizedUpstreamError, + }, + { status: response.status } + ); + } + + logTranslationEvent({ + provider, + model: body.model || "test-model", + sourceFormat, + targetFormat, + status: "success", + statusCode: 200, + latency: Date.now() - startedAt, + endpoint: "/api/translator/send", + }); + + // Return streaming response + return new Response(response.body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Error sending request:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/translator/translate/route.js b/src/app/api/translator/translate/route.js new file mode 100644 index 0000000000..4d296d5876 --- /dev/null +++ b/src/app/api/translator/translate/route.js @@ -0,0 +1,170 @@ +import { NextResponse } from "next/server"; +import { + detectFormat, + getTargetFormat, + buildProviderUrl, + buildProviderHeaders, +} from "@omniroute/open-sse/services/provider.js"; +import { translateRequest } from "@omniroute/open-sse/translator/index.js"; +import { FORMATS } from "@omniroute/open-sse/translator/formats.js"; +import { getProviderConnections } from "@/lib/localDb.js"; + +export async function POST(request) { + try { + const reqData = await request.json(); + const { + step, + provider, + body, + sourceFormat: reqSourceFormat, + targetFormat: reqTargetFormat, + } = reqData; + + if (!body) { + return NextResponse.json({ success: false, error: "Body is required" }, { status: 400 }); + } + + let result; + + // Direct translation mode (Playground): sourceFormat → targetFormat in one shot + if (step === "direct") { + const src = reqSourceFormat || detectFormat(body); + const tgt = reqTargetFormat || (provider ? getTargetFormat(provider) : "openai"); + const model = body.model || "test-model"; + const translated = translateRequest(src, tgt, model, body, true, null, provider); + return NextResponse.json({ + success: true, + sourceFormat: src, + targetFormat: tgt, + result: translated, + }); + } + + if (!step || !provider) { + return NextResponse.json( + { success: false, error: "Step and provider are required" }, + { status: 400 } + ); + } + + switch (step) { + case 1: { + // Step 1: Client → Source (detect format) + // Return format: { timestamp, endpoint, headers, body } + const actualBody = body.body || body; + const sourceFormat = detectFormat(actualBody); + + result = { + timestamp: body.timestamp || new Date().toISOString(), + endpoint: body.endpoint || "/v1/messages", + headers: body.headers || {}, + body: actualBody, + _detectedFormat: sourceFormat, + }; + break; + } + + case 2: { + // Step 2: Source → OpenAI + // Return format: { timestamp, headers: {}, body } + const actualBody = body.body || body; + const sourceFormat = detectFormat(actualBody); + const targetFormat = FORMATS.OPENAI; + const model = actualBody.model || "test-model"; + const translated = translateRequest( + sourceFormat, + targetFormat, + model, + actualBody, + true, + null, + provider + ); + + result = { + timestamp: new Date().toISOString(), + headers: {}, + body: translated, + }; + break; + } + + case 3: { + // Step 3: OpenAI → Target + // Return format: { timestamp, body } + const actualBody = body.body || body; + const sourceFormat = FORMATS.OPENAI; + const targetFormat = getTargetFormat(provider); + const model = actualBody.model || "test-model"; + const translated = translateRequest( + sourceFormat, + targetFormat, + model, + actualBody, + true, + null, + provider + ); + + result = { + timestamp: new Date().toISOString(), + body: translated, + }; + break; + } + + case 4: { + // Step 4: Build final request with real URL and headers + // Return format: { timestamp, url, headers, body } + const actualBody = body.body || body; + const model = actualBody.model || "test-model"; + + // Get provider credentials + const connections = await getProviderConnections({ provider }); + const connection = connections.find((c) => c.isActive !== false); + + if (!connection) { + return NextResponse.json( + { + success: false, + error: `No active connection found for provider: ${provider}`, + }, + { status: 400 } + ); + } + + const credentials = { + apiKey: connection.apiKey, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + copilotToken: connection.copilotToken, + projectId: connection.projectId, + providerSpecificData: connection.providerSpecificData, + }; + + // Build URL and headers + const url = buildProviderUrl(provider, model, true, { + baseUrlIndex: 0, + baseUrl: connection.providerSpecificData?.baseUrl, + }); + const headers = buildProviderHeaders(provider, credentials, true, actualBody); + + result = { + timestamp: new Date().toISOString(), + url: url, + headers: headers, + body: actualBody, + }; + break; + } + + default: + return NextResponse.json({ success: false, error: "Invalid step" }, { status: 400 }); + } + + return NextResponse.json({ success: true, result }); + } catch (error) { + console.error("Error translating:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js new file mode 100644 index 0000000000..3ca48f37dc --- /dev/null +++ b/src/app/api/usage/[connectionId]/route.js @@ -0,0 +1,150 @@ +import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; +import { getMachineId } from "@/shared/utils/machine"; +import { getUsageForProvider } from "@omniroute/open-sse/services/usage.js"; +import { getExecutor } from "@omniroute/open-sse/executors/index.js"; +import { syncToCloud } from "@/lib/cloudSync"; + +/** + * Sync to cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const machineId = await getMachineId(); + if (!machineId) return; + await syncToCloud(machineId); + } catch (error) { + console.error("[Usage API] Error syncing to cloud:", error); + } +} + +/** + * Refresh credentials using executor and update database + * @returns {{ connection, refreshed: boolean }} + */ +async function refreshAndUpdateCredentials(connection) { + const executor = getExecutor(connection.provider); + + // Build credentials object from connection + const credentials = { + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.tokenExpiresAt, + providerSpecificData: connection.providerSpecificData, + // For GitHub + copilotToken: connection.providerSpecificData?.copilotToken, + copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, + }; + + // Check if refresh is needed + const needsRefresh = executor.needsRefresh(credentials); + + if (!needsRefresh) { + return { connection, refreshed: false }; + } + + // Use executor's refreshCredentials method + const refreshResult = await executor.refreshCredentials(credentials, console); + + if (!refreshResult) { + // For GitHub, if refreshCredentials fails but we still have accessToken, try to use it directly + if (connection.provider === "github" && connection.accessToken) { + return { connection, refreshed: false }; + } + throw new Error("Failed to refresh credentials. Please re-authorize the connection."); + } + + // Build update object + const now = new Date().toISOString(); + const updateData = { + updatedAt: now, + }; + + // Update accessToken if present + if (refreshResult.accessToken) { + updateData.accessToken = refreshResult.accessToken; + } + + // Update refreshToken if present + if (refreshResult.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken; + } + + // Update token expiry + if (refreshResult.expiresIn) { + updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + } else if (refreshResult.expiresAt) { + updateData.tokenExpiresAt = refreshResult.expiresAt; + } + + // Handle provider-specific data (copilotToken for GitHub, etc.) + if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) { + updateData.providerSpecificData = { + ...connection.providerSpecificData, + copilotToken: refreshResult.copilotToken, + copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt, + }; + } + + // Update database + await updateProviderConnection(connection.id, updateData); + + // Return updated connection + const updatedConnection = { + ...connection, + ...updateData, + }; + + return { + connection: updatedConnection, + refreshed: true, + }; +} + +/** + * GET /api/usage/[connectionId] - Get usage data for a specific connection + */ +export async function GET(request, { params }) { + try { + const { connectionId } = await params; + + // Get connection from database + let connection = await getProviderConnectionById(connectionId); + if (!connection) { + return Response.json({ error: "Connection not found" }, { status: 404 }); + } + + // Only OAuth connections have usage APIs + if (connection.authType !== "oauth") { + return Response.json({ message: "Usage not available for API key connections" }); + } + + // Refresh credentials if needed using executor + let refreshed = false; + try { + const result = await refreshAndUpdateCredentials(connection); + connection = result.connection; + refreshed = result.refreshed; + + // Sync to cloud only if token was refreshed + if (refreshed) { + await syncToCloudIfEnabled(); + } + } catch (refreshError) { + console.error("[Usage API] Credential refresh failed:", refreshError); + return Response.json( + { + error: `Credential refresh failed: ${refreshError.message}`, + }, + { status: 401 } + ); + } + + // Fetch usage from provider API + const usage = await getUsageForProvider(connection); + return Response.json(usage); + } catch (error) { + console.error("[Usage API] Error fetching usage:", error); + console.error("[Usage API] Error stack:", error.stack); + return Response.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/usage/analytics/route.js b/src/app/api/usage/analytics/route.js new file mode 100644 index 0000000000..0ae8aea87e --- /dev/null +++ b/src/app/api/usage/analytics/route.js @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { getUsageDb } from "@/lib/usageDb.js"; +import { computeAnalytics } from "@/lib/usageAnalytics.js"; + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const range = searchParams.get("range") || "30d"; + + const db = await getUsageDb(); + const history = db.data.history || []; + + // Build connection map for account names + const { getProviderConnections } = await import("@/lib/localDb.js"); + let connectionMap = {}; + try { + const connections = await getProviderConnections(); + for (const conn of connections) { + connectionMap[conn.id] = conn.name || conn.email || conn.id; + } + } catch { + /* ignore */ + } + + const analytics = await computeAnalytics(history, range, connectionMap); + + return NextResponse.json(analytics); + } catch (error) { + console.error("Error computing analytics:", error); + return NextResponse.json({ error: "Failed to compute analytics" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/call-logs/[id]/route.js b/src/app/api/usage/call-logs/[id]/route.js new file mode 100644 index 0000000000..f975b15f47 --- /dev/null +++ b/src/app/api/usage/call-logs/[id]/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getCallLogById } from "@/lib/usageDb"; + +export async function GET(request, { params }) { + try { + const { id } = await params; + const log = await getCallLogById(id); + + if (!log) { + return NextResponse.json({ error: "Log not found" }, { status: 404 }); + } + + return NextResponse.json(log); + } catch (error) { + console.error("[API ERROR] /api/usage/call-logs/[id] failed:", error); + return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/call-logs/route.js b/src/app/api/usage/call-logs/route.js new file mode 100644 index 0000000000..eab08986df --- /dev/null +++ b/src/app/api/usage/call-logs/route.js @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getCallLogs } from "@/lib/usageDb"; + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + + const filter = {}; + if (searchParams.get("status")) filter.status = searchParams.get("status"); + if (searchParams.get("model")) filter.model = searchParams.get("model"); + if (searchParams.get("provider")) filter.provider = searchParams.get("provider"); + if (searchParams.get("account")) filter.account = searchParams.get("account"); + if (searchParams.get("apiKey")) filter.apiKey = searchParams.get("apiKey"); + if (searchParams.get("combo")) filter.combo = searchParams.get("combo"); + if (searchParams.get("search")) filter.search = searchParams.get("search"); + if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit")); + + const logs = await getCallLogs(filter); + return NextResponse.json(logs); + } catch (error) { + console.error("[API ERROR] /api/usage/call-logs failed:", error); + return NextResponse.json({ error: "Failed to fetch call logs" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/history/route.js b/src/app/api/usage/history/route.js new file mode 100644 index 0000000000..16a5d40725 --- /dev/null +++ b/src/app/api/usage/history/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getUsageStats } from "@/lib/usageDb"; + +export async function GET() { + try { + const stats = await getUsageStats(); + return NextResponse.json(stats); + } catch (error) { + console.error("Error fetching usage stats:", error); + return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/logs/route.js b/src/app/api/usage/logs/route.js new file mode 100644 index 0000000000..b5b875ec94 --- /dev/null +++ b/src/app/api/usage/logs/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getRecentLogs } from "@/lib/usageDb"; + +export async function GET() { + try { + const logs = await getRecentLogs(200); + return NextResponse.json(logs); + } catch (error) { + console.error("Error fetching logs:", error); + return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 }); + } +} diff --git a/src/app/api/usage/proxy-logs/route.js b/src/app/api/usage/proxy-logs/route.js new file mode 100644 index 0000000000..edff035d93 --- /dev/null +++ b/src/app/api/usage/proxy-logs/route.js @@ -0,0 +1,42 @@ +import { getProxyLogs, clearProxyLogs, getProxyLogStats } from "@/lib/proxyLogger"; + +/** + * GET /api/usage/proxy-logs — get proxy usage logs + * Query params: ?status=ok|error|timeout&type=http|socks5&provider=xxx&level=global|provider|combo|key&search=xxx&limit=300 + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + + const filters = {}; + if (searchParams.get("status")) filters.status = searchParams.get("status"); + if (searchParams.get("type")) filters.type = searchParams.get("type"); + if (searchParams.get("provider")) filters.provider = searchParams.get("provider"); + if (searchParams.get("level")) filters.level = searchParams.get("level"); + if (searchParams.get("search")) filters.search = searchParams.get("search"); + if (searchParams.get("limit")) filters.limit = parseInt(searchParams.get("limit"), 10); + + const logs = getProxyLogs(filters); + return Response.json(logs); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/usage/proxy-logs — clear all proxy logs + */ +export async function DELETE() { + try { + clearProxyLogs(); + return Response.json({ cleared: true }); + } catch (error) { + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/usage/request-logs/route.js b/src/app/api/usage/request-logs/route.js new file mode 100644 index 0000000000..0ae5e961cc --- /dev/null +++ b/src/app/api/usage/request-logs/route.js @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getRecentLogs } from "@/lib/usageDb"; + +export async function GET() { + try { + const logs = await getRecentLogs(200); + return NextResponse.json(logs); + } catch (error) { + console.error("[API ERROR] /api/usage/logs failed:", error); + console.error("[API ERROR] Stack:", error?.stack); + return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/api/chat/route.js b/src/app/api/v1/api/chat/route.js new file mode 100644 index 0000000000..2bdd7f7a6d --- /dev/null +++ b/src/app/api/v1/api/chat/route.js @@ -0,0 +1,37 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; +import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.js"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized"); + } +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function POST(request) { + await ensureInitialized(); + + const clonedReq = request.clone(); + let modelName = "llama3.2"; + try { + const body = await clonedReq.json(); + modelName = body.model || "llama3.2"; + } catch {} + + const response = await handleChat(request); + return transformToOllama(response, modelName); +} diff --git a/src/app/api/v1/chat/completions/route.js b/src/app/api/v1/chat/completions/route.js new file mode 100644 index 0000000000..af532b590b --- /dev/null +++ b/src/app/api/v1/chat/completions/route.js @@ -0,0 +1,36 @@ +import { callCloudWithMachineId } from "@/shared/utils/cloud.js"; +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function POST(request) { + // Fallback to local handling + await ensureInitialized(); + + return await handleChat(request); +} diff --git a/src/app/api/v1/embeddings/route.js b/src/app/api/v1/embeddings/route.js new file mode 100644 index 0000000000..e97ccdaa48 --- /dev/null +++ b/src/app/api/v1/embeddings/route.js @@ -0,0 +1,112 @@ +import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { + parseEmbeddingModel, + getAllEmbeddingModels, +} from "@omniroute/open-sse/config/embeddingRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; +import * as log from "@/sse/utils/logger.js"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * GET /v1/embeddings — list available embedding models + */ +export async function GET() { + const models = getAllEmbeddingModels(); + return new Response( + JSON.stringify({ + object: "list", + data: models.map((m) => ({ + id: m.id, + object: "model", + created: Math.floor(Date.now() / 1000), + owned_by: m.provider, + type: "embedding", + dimensions: m.dimensions, + })), + }), + { + headers: { "Content-Type": "application/json" }, + } + ); +} + +/** + * POST /v1/embeddings — create embeddings + */ +export async function POST(request) { + let body; + try { + body = await request.json(); + } catch { + log.warn("EMBED", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!body.model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + if (!body.input) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing input"); + } + + // Parse model to get provider + const { provider } = parseEmbeddingModel(body.model); + if (!provider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Invalid embedding model: ${body.model}. Use format: provider/model` + ); + } + + // Get credentials for the embedding provider + const credentials = await getProviderCredentials(provider); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for embedding provider: ${provider}` + ); + } + + const result = await handleEmbedding({ body, credentials, log }); + + if (result.success) { + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const errorPayload = toJsonErrorPayload(result.error, "Embedding provider error"); + return new Response(JSON.stringify(errorPayload), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/v1/images/generations/route.js b/src/app/api/v1/images/generations/route.js new file mode 100644 index 0000000000..385810f023 --- /dev/null +++ b/src/app/api/v1/images/generations/route.js @@ -0,0 +1,106 @@ +import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { parseImageModel, getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; +import * as log from "@/sse/utils/logger.js"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * GET /v1/images/generations — list available image models + */ +export async function GET() { + const models = getAllImageModels(); + return new Response( + JSON.stringify({ + object: "list", + data: models.map((m) => ({ + id: m.id, + object: "model", + created: Math.floor(Date.now() / 1000), + owned_by: m.provider, + type: "image", + supported_sizes: m.supportedSizes, + })), + }), + { + headers: { "Content-Type": "application/json" }, + } + ); +} + +/** + * POST /v1/images/generations — generate images + */ +export async function POST(request) { + let body; + try { + body = await request.json(); + } catch { + log.warn("IMAGE", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!body.model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string"); + } + + // Parse model to get provider + const { provider } = parseImageModel(body.model); + if (!provider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Invalid image model: ${body.model}. Use format: provider/model` + ); + } + + // Get credentials for the image provider + const credentials = await getProviderCredentials(provider); + if (!credentials) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for image provider: ${provider}`); + } + + const result = await handleImageGeneration({ body, credentials, log }); + + if (result.success) { + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error"); + return new Response(JSON.stringify(errorPayload), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/v1/messages/count_tokens/route.js b/src/app/api/v1/messages/count_tokens/route.js new file mode 100644 index 0000000000..b187845589 --- /dev/null +++ b/src/app/api/v1/messages/count_tokens/route.js @@ -0,0 +1,54 @@ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * POST /v1/messages/count_tokens - Mock token count response + */ +export async function POST(request) { + let body; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + // Estimate token count based on content length + const messages = body.messages || []; + let totalChars = 0; + for (const msg of messages) { + if (typeof msg.content === "string") { + totalChars += msg.content.length; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text" && part.text) { + totalChars += part.text.length; + } + } + } + } + + // Rough estimate: ~4 chars per token + const inputTokens = Math.ceil(totalChars / 4); + + return new Response( + JSON.stringify({ + input_tokens: inputTokens, + }), + { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} diff --git a/src/app/api/v1/messages/route.js b/src/app/api/v1/messages/route.js new file mode 100644 index 0000000000..c040690099 --- /dev/null +++ b/src/app/api/v1/messages/route.js @@ -0,0 +1,36 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/messages"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/messages - Claude format (auto convert via handleChat) + */ +export async function POST(request) { + await ensureInitialized(); + return await handleChat(request); +} diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js new file mode 100644 index 0000000000..dd61d91158 --- /dev/null +++ b/src/app/api/v1/models/route.js @@ -0,0 +1,275 @@ +import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb"; +import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.js"; +import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.js"; + +const FALLBACK_ALIAS_TO_PROVIDER = { + ag: "antigravity", + cc: "claude", + cl: "cline", + cu: "cursor", + cx: "codex", + gc: "gemini-cli", + gh: "github", + if: "iflow", + kc: "kilocode", + kmc: "kimi-coding", + kr: "kiro", + qw: "qwen", +}; + +function buildAliasMaps() { + const aliasToProviderId = {}; + const providerIdToAlias = {}; + + // Canonical source for ID/alias pairs used across dashboard/provider config. + for (const provider of Object.values(AI_PROVIDERS)) { + const providerId = provider?.id; + const alias = provider?.alias || providerId; + if (!providerId) continue; + aliasToProviderId[providerId] = providerId; + aliasToProviderId[alias] = providerId; + if (!providerIdToAlias[providerId]) { + providerIdToAlias[providerId] = alias; + } + } + + for (const [left, right] of Object.entries(PROVIDER_ID_TO_ALIAS)) { + // Handle both possible directions: + // - providerId -> alias + // - alias -> providerId + if (PROVIDER_MODELS[left]) { + aliasToProviderId[left] = aliasToProviderId[left] || right; + continue; + } + if (PROVIDER_MODELS[right]) { + aliasToProviderId[right] = aliasToProviderId[right] || left; + continue; + } + aliasToProviderId[right] = aliasToProviderId[right] || left; + } + + for (const alias of Object.keys(PROVIDER_MODELS)) { + if (!aliasToProviderId[alias]) { + aliasToProviderId[alias] = alias; + } + } + + for (const [alias, providerId] of Object.entries(aliasToProviderId)) { + if (!providerIdToAlias[providerId]) { + providerIdToAlias[providerId] = alias; + } + } + + // Safety net for environments where alias maps are partially loaded during + // module initialization/circular imports. + for (const [alias, providerId] of Object.entries(FALLBACK_ALIAS_TO_PROVIDER)) { + if (!aliasToProviderId[alias]) aliasToProviderId[alias] = providerId; + if (!aliasToProviderId[providerId]) aliasToProviderId[providerId] = providerId; + if (!providerIdToAlias[providerId]) providerIdToAlias[providerId] = alias; + } + + return { aliasToProviderId, providerIdToAlias }; +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * GET /v1/models - OpenAI compatible models list + * Returns models from all active providers, combos, embeddings, and image models in OpenAI format + */ +export async function GET() { + try { + const { aliasToProviderId, providerIdToAlias } = buildAliasMaps(); + + // Get active provider connections + let connections = []; + try { + connections = await getProviderConnections(); + // Filter to only active connections + connections = connections.filter((c) => c.isActive !== false); + } catch (e) { + // If database not available, return all models + console.log("Could not fetch providers, returning all models"); + } + + // Get combos + let combos = []; + try { + combos = await getCombos(); + } catch (e) { + console.log("Could not fetch combos"); + } + + // Build set of active provider aliases + const activeAliases = new Set(); + for (const conn of connections) { + const alias = providerIdToAlias[conn.provider] || conn.provider; + activeAliases.add(alias); + activeAliases.add(conn.provider); + } + + // Collect models from active providers (or all if none active) + const models = []; + const timestamp = Math.floor(Date.now() / 1000); + + // Add combos first (they appear at the top) + for (const combo of combos) { + models.push({ + id: combo.name, + object: "model", + created: timestamp, + owned_by: "combo", + permission: [], + root: combo.name, + parent: null, + }); + } + + // Add provider models (chat) + for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) { + const providerId = aliasToProviderId[alias] || alias; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId; + + // If we have active providers, only include those; otherwise include all + if ( + connections.length > 0 && + !activeAliases.has(alias) && + !activeAliases.has(canonicalProviderId) + ) { + continue; + } + + for (const model of providerModels) { + const aliasId = `${alias}/${model.id}`; + models.push({ + id: aliasId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: model.id, + parent: null, + }); + + // Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model). + // This improves compatibility for clients that expect full provider names. + if (canonicalProviderId !== alias) { + models.push({ + id: `${canonicalProviderId}/${model.id}`, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: model.id, + parent: aliasId, + }); + } + } + } + + // Add embedding models + for (const embModel of getAllEmbeddingModels()) { + models.push({ + id: embModel.id, + object: "model", + created: timestamp, + owned_by: embModel.provider, + type: "embedding", + dimensions: embModel.dimensions, + }); + } + + // Add image models + for (const imgModel of getAllImageModels()) { + models.push({ + id: imgModel.id, + object: "model", + created: timestamp, + owned_by: imgModel.provider, + type: "image", + supported_sizes: imgModel.supportedSizes, + }); + } + + // Add custom models (user-defined) + try { + const customModelsMap = await getAllCustomModels(); + for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) { + const alias = providerIdToAlias[providerId] || providerId; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId; + // Only include if provider is active (or no connections configured) + if ( + connections.length > 0 && + !activeAliases.has(alias) && + !activeAliases.has(canonicalProviderId) + ) + continue; + + for (const model of providerCustomModels) { + // Skip if already added as built-in + const aliasId = `${alias}/${model.id}`; + if (models.some((m) => m.id === aliasId)) continue; + + models.push({ + id: aliasId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: model.id, + parent: null, + custom: true, + }); + + if (canonicalProviderId !== alias) { + const providerPrefixedId = `${canonicalProviderId}/${model.id}`; + if (models.some((m) => m.id === providerPrefixedId)) continue; + models.push({ + id: providerPrefixedId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: model.id, + parent: aliasId, + custom: true, + }); + } + } + } + } catch (e) { + console.log("Could not fetch custom models"); + } + + return Response.json( + { + object: "list", + data: models, + }, + { + headers: { + "Access-Control-Allow-Origin": "*", + }, + } + ); + } catch (error) { + console.log("Error fetching models:", error); + return Response.json( + { error: { message: error.message, type: "server_error" } }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.js b/src/app/api/v1/providers/[provider]/chat/completions/route.js new file mode 100644 index 0000000000..084b11e441 --- /dev/null +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.js @@ -0,0 +1,87 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.js"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/providers/{provider}/chat/completions + * Routes to the specified provider, validating model/provider match. + */ +export async function POST(request, { params }) { + const { provider: rawProvider } = await params; + + const providerEntry = getRegistryEntry(rawProvider); + + if (!providerEntry) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${rawProvider}`); + } + + // Resolve provider alias/id for model prefix checks + const providerAlias = providerEntry.alias || providerEntry.id; + + await ensureInitialized(); + + // Clone request with provider-prefixed model + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Validate model belongs to this provider + if (body.model) { + const modelParts = body.model.split("/"); + const hasProviderPrefix = modelParts.length >= 2; + const modelProvider = hasProviderPrefix ? modelParts[0] : null; + + if ( + hasProviderPrefix && + modelProvider !== providerAlias && + modelProvider !== rawProvider && + modelProvider !== providerEntry.id + ) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Model "${body.model}" does not belong to provider "${rawProvider}". Expected prefix: ${providerAlias}/` + ); + } + + // Add provider prefix if missing + if (!hasProviderPrefix) { + body.model = `${providerAlias}/${body.model}`; + } + } + + // Create a new request with the modified body + const newRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: JSON.stringify(body), + }); + + return await handleChat(newRequest); +} diff --git a/src/app/api/v1/providers/[provider]/embeddings/route.js b/src/app/api/v1/providers/[provider]/embeddings/route.js new file mode 100644 index 0000000000..a15efc90b4 --- /dev/null +++ b/src/app/api/v1/providers/[provider]/embeddings/route.js @@ -0,0 +1,84 @@ +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.js"; +import * as log from "@/sse/utils/logger.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/providers/{provider}/embeddings + */ +export async function POST(request, { params }) { + const { provider: rawProvider } = await params; + + const providerEntry = getRegistryEntry(rawProvider); + + if (!providerEntry) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${rawProvider}`); + } + + const providerAlias = providerEntry.alias || providerEntry.id; + + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey || !(await isValidApiKey(apiKey))) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + // Add provider prefix if missing + if (body.model && !body.model.includes("/")) { + body.model = `${providerAlias}/${body.model}`; + } + + // Validate provider match + if (body.model) { + const prefix = body.model.split("/")[0]; + if (prefix !== providerAlias && prefix !== rawProvider && prefix !== providerEntry.id) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Model "${body.model}" does not belong to provider "${rawProvider}"` + ); + } + } + + const credentials = await getProviderCredentials(providerEntry.id); + if (!credentials) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${rawProvider}`); + } + + const result = await handleEmbedding({ body, credentials, log }); + + if (result.success) { + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ error: result.error }), { + status: result.status || 500, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.js b/src/app/api/v1/providers/[provider]/images/generations/route.js new file mode 100644 index 0000000000..bf6218868a --- /dev/null +++ b/src/app/api/v1/providers/[provider]/images/generations/route.js @@ -0,0 +1,93 @@ +import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.js"; +import * as log from "@/sse/utils/logger.js"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/providers/{provider}/images/generations + */ +export async function POST(request, { params }) { + const { provider: rawProvider } = await params; + + // Verify this is a valid image provider + const imageProvider = getImageProvider(rawProvider); + if (!imageProvider) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown image provider: ${rawProvider}`); + } + + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + const apiKey = extractApiKey(request); + if (!apiKey || !(await isValidApiKey(apiKey))) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!body.model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt"); + } + + // Ensure model has provider prefix + if (!body.model.includes("/")) { + body.model = `${rawProvider}/${body.model}`; + } + + // Validate provider match + const modelProvider = body.model.split("/")[0]; + if (modelProvider !== rawProvider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Model "${body.model}" does not belong to image provider "${rawProvider}"` + ); + } + + const credentials = await getProviderCredentials(rawProvider); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for image provider: ${rawProvider}` + ); + } + + const result = await handleImageGeneration({ body, credentials, log }); + + if (result.success) { + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error"); + return new Response(JSON.stringify(errorPayload), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/v1/responses/route.js b/src/app/api/v1/responses/route.js new file mode 100644 index 0000000000..6939ab437e --- /dev/null +++ b/src/app/api/v1/responses/route.js @@ -0,0 +1,31 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/responses"); + } +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/responses - OpenAI Responses API format + * Now handled by translator pattern (openai-responses format auto-detected) + */ +export async function POST(request) { + await ensureInitialized(); + return await handleChat(request); +} diff --git a/src/app/api/v1/route.js b/src/app/api/v1/route.js new file mode 100644 index 0000000000..1643270183 --- /dev/null +++ b/src/app/api/v1/route.js @@ -0,0 +1,34 @@ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /v1 - Return models list (OpenAI compatible) + */ +export async function GET() { + const models = [ + { id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" }, + { id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" }, + { id: "gpt-4o", object: "model", owned_by: "openai" }, + { id: "gemini-2.5-pro", object: "model", owned_by: "google" }, + ]; + + return new Response( + JSON.stringify({ + object: "list", + data: models, + }), + { + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} diff --git a/src/app/api/v1beta/models/[...path]/route.js b/src/app/api/v1beta/models/[...path]/route.js new file mode 100644 index 0000000000..4e1705df34 --- /dev/null +++ b/src/app/api/v1beta/models/[...path]/route.js @@ -0,0 +1,109 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "@omniroute/open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1beta/models"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1beta/models/{model}:generateContent - Gemini compatible endpoint + * Converts Gemini format to internal format and handles via handleChat + */ +export async function POST(request, { params }) { + await ensureInitialized(); + + try { + const { path } = await params; + // path = ["provider", "model:generateContent"] or ["model:generateContent"] + + let model; + if (path.length >= 2) { + // Format: /v1beta/models/provider/model:generateContent + const provider = path[0]; + const modelAction = path[1]; + const modelName = modelAction + .replace(":generateContent", "") + .replace(":streamGenerateContent", ""); + model = `${provider}/${modelName}`; + } else { + // Format: /v1beta/models/model:generateContent + const modelAction = path[0]; + model = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", ""); + } + + const body = await request.json(); + + // Convert Gemini format to OpenAI/internal format + const convertedBody = convertGeminiToInternal(body, model); + + // Create new request with converted body + const newRequest = new Request(request.url, { + method: "POST", + headers: request.headers, + body: JSON.stringify(convertedBody), + }); + + return await handleChat(newRequest); + } catch (error) { + console.log("Error handling Gemini request:", error); + return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 }); + } +} + +/** + * Convert Gemini request format to internal format + */ +function convertGeminiToInternal(geminiBody, model) { + const messages = []; + + // Convert system instruction + if (geminiBody.systemInstruction) { + const systemText = geminiBody.systemInstruction.parts?.map((p) => p.text).join("\n") || ""; + if (systemText) { + messages.push({ role: "system", content: systemText }); + } + } + + // Convert contents to messages + if (geminiBody.contents) { + for (const content of geminiBody.contents) { + const role = content.role === "model" ? "assistant" : "user"; + const text = content.parts?.map((p) => p.text).join("\n") || ""; + messages.push({ role, content: text }); + } + } + + // Determine if streaming + const stream = geminiBody.generationConfig?.stream !== false; + + return { + model, + messages, + stream, + max_tokens: geminiBody.generationConfig?.maxOutputTokens, + temperature: geminiBody.generationConfig?.temperature, + top_p: geminiBody.generationConfig?.topP, + }; +} diff --git a/src/app/api/v1beta/models/route.js b/src/app/api/v1beta/models/route.js new file mode 100644 index 0000000000..eaa73f6a84 --- /dev/null +++ b/src/app/api/v1beta/models/route.js @@ -0,0 +1,43 @@ +import { PROVIDER_MODELS } from "@/shared/constants/models"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * GET /v1beta/models - Gemini compatible models list + * Returns models in Gemini API format + */ +export async function GET() { + try { + // Collect all models from all providers + const models = []; + + for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { + for (const model of providerModels) { + models.push({ + name: `models/${provider}/${model.id}`, + displayName: model.name || model.id, + description: `${provider} model: ${model.name || model.id}`, + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: 128000, + outputTokenLimit: 8192, + }); + } + } + + return Response.json({ models }); + } catch (error) { + console.log("Error fetching models:", error); + return Response.json({ error: { message: error.message } }, { status: 500 }); + } +} diff --git a/src/app/callback/page.js b/src/app/callback/page.js new file mode 100644 index 0000000000..dd960a0f25 --- /dev/null +++ b/src/app/callback/page.js @@ -0,0 +1,162 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +/** + * OAuth Callback Page Content + */ +function CallbackContent() { + const searchParams = useSearchParams(); + const [status, setStatus] = useState("processing"); + + useEffect(() => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + const errorDescription = searchParams.get("error_description"); + + const callbackData = { + code, + state, + error, + errorDescription, + fullUrl: window.location.href, + }; + + let sent = false; + + // Check if this callback is from expected origin/port + const expectedOrigins = [ + window.location.origin, // Same origin (for most providers) + "http://localhost:1455", // Codex specific port + ]; + + // Method 1: postMessage to opener (popup mode) + if (window.opener) { + try { + window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev + sent = true; + } catch (e) { + console.log("postMessage failed:", e); + } + } + + // Method 2: BroadcastChannel (same origin tabs) + try { + const channel = new BroadcastChannel("oauth_callback"); + channel.postMessage(callbackData); + channel.close(); + sent = true; + } catch (e) { + console.log("BroadcastChannel failed:", e); + } + + // Method 3: localStorage event (fallback) + try { + localStorage.setItem( + "oauth_callback", + JSON.stringify({ ...callbackData, timestamp: Date.now() }) + ); + sent = true; + } catch (e) { + console.log("localStorage failed:", e); + } + + if (sent && (code || error)) { + // Use setTimeout to avoid synchronous setState in effect + setTimeout(() => { + // Only auto-close if opened as popup (has opener) — remote access keeps tab open + if (window.opener) { + setStatus("success"); + setTimeout(() => { + window.close(); + // If can't close (not a popup), show success message + setTimeout(() => setStatus("done"), 500); + }, 1500); + } else { + // Opened as new tab (remote access) — show URL for manual copy + setStatus("done"); + } + }, 0); + } else { + setTimeout(() => setStatus("manual"), 0); + } + }, [searchParams]); + + return ( +
+
+ {status === "processing" && ( + <> +
+ + progress_activity + +
+

Processing...

+

Please wait while we complete the authorization.

+ + )} + + {(status === "success" || status === "done") && ( + <> +
+ + check_circle + +
+

Authorization Successful!

+

+ {status === "success" + ? "This window will close automatically..." + : "You can close this tab now."} +

+ + )} + + {status === "manual" && ( + <> +
+ info +
+

Copy This URL

+

+ Please copy the URL from the address bar and paste it in the application. +

+
+ + {typeof window !== "undefined" ? window.location.href : ""} + +
+ + )} +
+
+ ); +} + +/** + * OAuth Callback Page + * Receives callback from OAuth providers and sends data back via multiple methods + */ +export default function CallbackPage() { + return ( + +
+
+ + progress_activity + +
+

Loading...

+
+ + } + > + +
+ ); +} diff --git a/src/app/dashboard/settings/pricing/page.js b/src/app/dashboard/settings/pricing/page.js new file mode 100644 index 0000000000..f89efe7589 --- /dev/null +++ b/src/app/dashboard/settings/pricing/page.js @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Card from "@/shared/components/Card"; +import PricingModal from "@/shared/components/PricingModal"; + +export default function PricingSettingsPage() { + const router = useRouter(); + const [showModal, setShowModal] = useState(false); + const [currentPricing, setCurrentPricing] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadPricing(); + }, []); + + const loadPricing = async () => { + setLoading(true); + try { + const response = await fetch("/api/pricing"); + if (response.ok) { + const data = await response.json(); + setCurrentPricing(data); + } + } catch (error) { + console.error("Failed to load pricing:", error); + } finally { + setLoading(false); + } + }; + + const handlePricingUpdated = () => { + loadPricing(); + }; + + // Count total models with pricing + const getModelCount = () => { + if (!currentPricing) return 0; + let count = 0; + for (const provider in currentPricing) { + count += Object.keys(currentPricing[provider]).length; + } + return count; + }; + + // Get providers list + const getProviders = () => { + if (!currentPricing) return []; + return Object.keys(currentPricing).sort(); + }; + + return ( +
+ {/* Header */} +
+
+

Pricing Settings

+

+ Configure pricing rates for cost tracking and calculations +

+
+ +
+ + {/* Quick Stats */} +
+ +
Total Models
+
{loading ? "..." : getModelCount()}
+
+ +
Providers
+
{loading ? "..." : getProviders().length}
+
+ +
Status
+
{loading ? "..." : "Active"}
+
+
+ + {/* Info Section */} + +

How Pricing Works

+
+

+ Cost Calculation: Costs are calculated based on token usage and pricing + rates. Each request's cost is determined by: (input_tokens × input_rate) + + (output_tokens × output_rate) + (cached_tokens × cached_rate) +

+

+ Pricing Format: All rates are in{" "} + dollars per million tokens ($/1M tokens). Example: An input rate of + 2.50 means $2.50 per 1,000,000 input tokens. +

+

+ Token Types: +

+
    +
  • + Input: Standard prompt tokens +
  • +
  • + Output: Completion/response tokens +
  • +
  • + Cached: Cached input tokens (typically 50% of input rate) +
  • +
  • + Reasoning: Special reasoning/thinking tokens (fallback to output + rate) +
  • +
  • + Cache Creation: Tokens used to create cache entries (fallback to + input rate) +
  • +
+

+ Custom Pricing: You can override default pricing for specific models. + Reset to defaults anytime to restore standard rates. +

+
+
+ + {/* Current Pricing Preview */} + +
+

Current Pricing Overview

+ +
+ + {loading ? ( +
Loading pricing data...
+ ) : currentPricing ? ( +
+ {Object.keys(currentPricing) + .slice(0, 5) + .map((provider) => ( +
+ {provider.toUpperCase()}:{" "} + + {Object.keys(currentPricing[provider]).length} models + +
+ ))} + {Object.keys(currentPricing).length > 5 && ( +
+ + {Object.keys(currentPricing).length - 5} more providers +
+ )} +
+ ) : ( +
No pricing data available
+ )} +
+ + {/* Pricing Modal */} + {showModal && ( + setShowModal(false)} + onSave={handlePricingUpdated} + /> + )} +
+ ); +} diff --git a/src/app/docs/page.js b/src/app/docs/page.js new file mode 100644 index 0000000000..2815913362 --- /dev/null +++ b/src/app/docs/page.js @@ -0,0 +1,188 @@ +import Link from "next/link"; +import { APP_CONFIG } from "@/shared/constants/config"; + +const endpointRows = [ + { path: "/v1/chat/completions", note: "OpenAI-compatible chat endpoint (default)." }, + { path: "/v1/responses", note: "Responses API endpoint (supported)." }, + { path: "/v1/models", note: "Model catalog for connected providers." }, + { path: "/chat/completions", note: "Rewrite helper for clients that do not include /v1." }, + { path: "/responses", note: "Rewrite helper for Responses clients without /v1." }, + { path: "/models", note: "Rewrite helper for model discovery without /v1." }, +]; + +const useCases = [ + { + title: "Single endpoint for many providers", + text: "Point clients to one base URL and route by model prefix (for example: gh/, cc/, kr/, openai/).", + }, + { + title: "Fallback and model switching with combos", + text: "Create combo models in Dashboard and keep client config stable while providers rotate internally.", + }, + { + title: "Usage, cost and debug visibility", + text: "Track tokens/cost by provider, account and API key in Usage + Logger tabs.", + }, +]; + +const troubleshootingItems = [ + "If the client fails with model routing, use explicit provider/model (for example: gh/gpt-5.1-codex).", + "If you receive ambiguous model errors, pick a provider prefix instead of a bare model ID.", + "For GitHub Codex-family models, keep model as gh/; router selects /responses automatically.", + "Use Dashboard > Providers > Test Connection before testing from IDEs or external clients.", +]; + +export default function DocsPage() { + return ( +
+
+
+
+
+

+ In-App Documentation +

+

{APP_CONFIG.name} Docs

+

+ Quick setup, client compatibility notes, and endpoint reference to run + OpenAI-compatible clients, Codex/Copilot models, and Cherry Studio integrations on + this server. +

+
+
+ + Open Endpoint Page + + + Report Issue + +
+
+
+ +
+

Quick Start

+
    +
  1. + 1. Create API key +

    Generate one key per app/environment.

    +
  2. +
  3. + 2. Connect providers +

    + Add provider accounts in Dashboard and run Test Connection. +

    +
  4. +
  5. + 3. Set client base URL +

    + Prefer https://<host>/v1. +

    +
  6. +
  7. + 4. Choose model +

    + Prefer explicit provider prefix, for example{" "} + gh/gpt-5.1-codex. +

    +
  8. +
+
+ +
+

Common Use Cases

+
+ {useCases.map((item) => ( +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+ +
+

Client Compatibility

+
+
+

Cherry Studio

+
    +
  • + Base URL:{" "} + https://<host>/v1 +
  • +
  • + Chat endpoint:{" "} + /chat/completions +
  • +
  • + Model recommendation: explicit prefix ( + gh/...,{" "} + cc/...) +
  • +
+
+
+

Codex / GitHub Copilot Models

+
    +
  • + Use model IDs with gh/ prefix. +
  • +
  • + Codex-family models auto-route to{" "} + /responses. +
  • +
  • + Non-Codex models continue on{" "} + /chat/completions. +
  • +
+
+
+
+ +
+

Endpoint Reference

+
+ + + + + + + + + {endpointRows.map((row) => ( + + + + + ))} + +
PathNotes
{row.path}{row.note}
+
+
+ +
+

Troubleshooting

+
    + {troubleshootingItems.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000000..ab0f4ee4b3 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,271 @@ +@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"); +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* macOS-inspired Color Palette with Terracotta Primary */ +:root { + /* Primary - Warm Coral/Terracotta */ + --color-primary: #d97757; + --color-primary-hover: #c56243; + + /* Light theme */ + --color-bg: #fbf9f6; + --color-bg-alt: #f5f1ed; + --color-surface: #ffffff; + --color-sidebar: rgba(246, 246, 246, 0.8); + --color-border: rgba(0, 0, 0, 0.1); + --color-text-main: #383733; + --color-text-muted: #75736e; + + /* Shadows - subtle macOS style */ + --shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 12px rgba(0, 0, 0, 0.015); + --shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.12); + --shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06); +} + +.dark { + /* Dark theme */ + --color-bg: #191918; + --color-bg-alt: #1f1f1e; + --color-surface: #242423; + --color-sidebar: rgba(30, 30, 30, 0.8); + --color-border: rgba(255, 255, 255, 0.1); + --color-text-main: #ecebe8; + --color-text-muted: #9e9d99; + + /* Dark shadows - subtle macOS style */ + --shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1); + --shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.15); + --shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.3); +} + +@theme inline { + /* Primary */ + --color-primary: var(--color-primary); + --color-primary-hover: var(--color-primary-hover); + + /* Auto-switch colors (use CSS variables from :root/.dark) */ + --color-bg: var(--color-bg); + --color-surface: var(--color-surface); + --color-sidebar: var(--color-sidebar); + --color-border: var(--color-border); + --color-text-main: var(--color-text-main); + --color-text-muted: var(--color-text-muted); + + /* Static colors (for explicit light/dark usage) */ + --color-bg-light: #fbf9f6; + --color-bg-dark: #191918; + --color-surface-light: #ffffff; + --color-surface-dark: #242423; + --color-sidebar-light: #f0efec; + --color-sidebar-dark: #1f1f1e; + --color-border-light: #e6e4dd; + --color-border-dark: #333331; + --color-text-main-light: #383733; + --color-text-main-dark: #ecebe8; + --color-text-muted-light: #75736e; + --color-text-muted-dark: #9e9d99; + + /* Shadows */ + --shadow-soft: var(--shadow-soft); + --shadow-warm: var(--shadow-warm); + --shadow-elevated: var(--shadow-elevated); + + /* Font - macOS system fonts */ + --font-sans: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif; +} + +/* Base styles */ +body { + background-color: var(--color-bg); + color: var(--color-text-main); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Selection */ +::selection { + background-color: rgba(217, 119, 87, 0.2); + color: var(--color-primary); +} + +/* Global custom scrollbar — thin, minimal, theme-aware */ +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.2); + border-radius: 9999px; + transition: background-color 0.2s; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.45); +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +/* Dark theme scrollbar — slightly brighter thumb */ +.dark *::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.08); +} + +.dark *::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +/* Firefox support */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.2) transparent; +} + +.dark * { + scrollbar-color: rgba(255, 255, 255, 0.08) transparent; +} + +/* Keep .custom-scrollbar alias for backward compatibility */ +.custom-scrollbar { + scrollbar-width: thin; +} + +/* Hero gradient */ +.bg-hero-gradient { + background: linear-gradient(180deg, #f5f1ed 0%, #fefcfb 100%); +} + +.dark .bg-hero-gradient { + background: linear-gradient(180deg, #1f1f1e 0%, #191918 100%); +} + +/* Material Symbols */ +.material-symbols-outlined { + font-family: "Material Symbols Outlined", sans-serif; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + font-feature-settings: "liga"; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; + font-variation-settings: + "FILL" 0, + "wght" 400, + "GRAD" 0, + "opsz" 24; + user-select: none; +} + +/* Ensure icon clicks always bubble to the parent button */ +button .material-symbols-outlined, +[role="button"] .material-symbols-outlined { + pointer-events: none; +} + +.material-symbols-outlined.fill-1 { + font-variation-settings: + "FILL" 1, + "wght" 400, + "GRAD" 0, + "opsz" 24; +} + +/* Animations */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes border-glow { + 0%, + 100% { + box-shadow: + 0 0 5px rgba(217, 119, 87, 0.3), + 0 0 10px rgba(217, 119, 87, 0.2); + border-color: rgba(217, 119, 87, 0.5); + } + 50% { + box-shadow: + 0 0 10px rgba(217, 119, 87, 0.5), + 0 0 20px rgba(217, 119, 87, 0.3); + border-color: rgba(217, 119, 87, 0.8); + } +} + +.animate-border-glow { + animation: border-glow 2s ease-in-out infinite; +} + +/* macOS Vibrancy/Blur Effect */ +.bg-vibrancy { + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background: rgba(255, 255, 255, 0.72); +} + +.dark .bg-vibrancy { + background: rgba(30, 30, 30, 0.72); +} + +/* macOS Traffic Lights */ +.traffic-lights { + display: flex; + gap: 8px; +} + +.traffic-light { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.traffic-light.red { + background: #ff5f56; +} + +.traffic-light.yellow { + background: #ffbd2e; +} + +.traffic-light.green { + background: #27c93f; +} diff --git a/src/app/landing/components/AnimatedBackground.js b/src/app/landing/components/AnimatedBackground.js new file mode 100644 index 0000000000..2f434cef64 --- /dev/null +++ b/src/app/landing/components/AnimatedBackground.js @@ -0,0 +1,58 @@ +"use client"; + +export default function AnimatedBackground() { + return ( + <> + {/* Animated Background */} +
+ {/* Grid pattern */} +
+ + {/* Animated gradient orbs */} +
+
+
+ + {/* Vignette effect */} +
+
+ + {/* CSS Animations */} + + + ); +} diff --git a/src/app/landing/components/Features.js b/src/app/landing/components/Features.js new file mode 100644 index 0000000000..d4015c209f --- /dev/null +++ b/src/app/landing/components/Features.js @@ -0,0 +1,136 @@ +"use client"; + +const FEATURES = [ + { + icon: "link", + title: "Unified Endpoint", + desc: "Access all providers via a single standard API URL.", + colors: { + border: "hover:border-blue-500/50", + bg: "hover:bg-blue-500/5", + iconBg: "bg-blue-500/10", + iconText: "text-blue-500", + titleHover: "group-hover:text-blue-400", + }, + }, + { + icon: "bolt", + title: "Easy Setup", + desc: "Get up and running in minutes with npx command.", + colors: { + border: "hover:border-orange-500/50", + bg: "hover:bg-orange-500/5", + iconBg: "bg-orange-500/10", + iconText: "text-orange-500", + titleHover: "group-hover:text-orange-400", + }, + }, + { + icon: "shield_with_heart", + title: "Model Fallback", + desc: "Automatically switch providers on failure or high latency.", + colors: { + border: "hover:border-rose-500/50", + bg: "hover:bg-rose-500/5", + iconBg: "bg-rose-500/10", + iconText: "text-rose-500", + titleHover: "group-hover:text-rose-400", + }, + }, + { + icon: "monitoring", + title: "Usage Tracking", + desc: "Detailed analytics and cost monitoring across all models.", + colors: { + border: "hover:border-purple-500/50", + bg: "hover:bg-purple-500/5", + iconBg: "bg-purple-500/10", + iconText: "text-purple-500", + titleHover: "group-hover:text-purple-400", + }, + }, + { + icon: "key", + title: "OAuth & API Keys", + desc: "Securely manage credentials in one vault.", + colors: { + border: "hover:border-amber-500/50", + bg: "hover:bg-amber-500/5", + iconBg: "bg-amber-500/10", + iconText: "text-amber-500", + titleHover: "group-hover:text-amber-400", + }, + }, + { + icon: "cloud_sync", + title: "Cloud Sync", + desc: "Sync your configurations across devices instantly.", + colors: { + border: "hover:border-sky-500/50", + bg: "hover:bg-sky-500/5", + iconBg: "bg-sky-500/10", + iconText: "text-sky-500", + titleHover: "group-hover:text-sky-400", + }, + }, + { + icon: "terminal", + title: "CLI Support", + desc: "Works with Claude Code, Codex, Cline, Cursor, and more.", + colors: { + border: "hover:border-emerald-500/50", + bg: "hover:bg-emerald-500/5", + iconBg: "bg-emerald-500/10", + iconText: "text-emerald-500", + titleHover: "group-hover:text-emerald-400", + }, + }, + { + icon: "dashboard", + title: "Dashboard", + desc: "Visual dashboard for real-time traffic analysis.", + colors: { + border: "hover:border-fuchsia-500/50", + bg: "hover:bg-fuchsia-500/5", + iconBg: "bg-fuchsia-500/10", + iconText: "text-fuchsia-500", + titleHover: "group-hover:text-fuchsia-400", + }, + }, +]; + +export default function Features() { + return ( +
+
+
+

Powerful Features

+

+ Everything you need to manage your AI infrastructure in one place, built for scale. +

+
+ +
+ {FEATURES.map((feature) => ( +
+
+ {feature.icon} +
+

+ {feature.title} +

+

{feature.desc}

+
+ ))} +
+
+
+ ); +} diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js new file mode 100644 index 0000000000..dc68f6a85d --- /dev/null +++ b/src/app/landing/components/FlowAnimation.js @@ -0,0 +1,150 @@ +"use client"; +import { useEffect, useState } from "react"; +import Image from "next/image"; + +const CLI_TOOLS = [ + { id: "claude", name: "Claude Code", image: "/providers/claude.png" }, + { id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" }, + { id: "cline", name: "Cline", image: "/providers/cline.png" }, + { id: "cursor", name: "Cursor", image: "/providers/cursor.png" }, +]; + +const PROVIDERS = [ + { id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" }, + { id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" }, + { id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" }, + { id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" }, +]; + +export default function FlowAnimation() { + const [activeFlow, setActiveFlow] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setActiveFlow((prev) => (prev + 1) % PROVIDERS.length); + }, 2000); + return () => clearInterval(interval); + }, []); + + return ( +
+ {/* OmniRoute Hub - Center */} +
+ hub + OmniRoute +
+
+ + {/* CLI Tools - Left side */} +
+ {CLI_TOOLS.map((tool) => ( +
+
+ {tool.name} +
+
+ ))} +
+ + {/* SVG Lines from CLI to OmniRoute */} + + + + + + + + {/* SVG Lines from OmniRoute to Providers */} + + + + + + + + {/* AI Providers - Right side */} +
+ {PROVIDERS.map((provider, idx) => ( +
+ {provider.name} +
+ ))} +
+ + {/* Mobile fallback */} +
+

Interactive diagram visible on desktop

+
+
+ ); +} diff --git a/src/app/landing/components/Footer.js b/src/app/landing/components/Footer.js new file mode 100644 index 0000000000..aacaa5f434 --- /dev/null +++ b/src/app/landing/components/Footer.js @@ -0,0 +1,123 @@ +"use client"; + +export default function Footer() { + return ( + + ); +} diff --git a/src/app/landing/components/GetStarted.js b/src/app/landing/components/GetStarted.js new file mode 100644 index 0000000000..ebbd89f530 --- /dev/null +++ b/src/app/landing/components/GetStarted.js @@ -0,0 +1,118 @@ +"use client"; +import { useState } from "react"; + +export default function GetStarted() { + const [copied, setCopied] = useState(false); + + const handleCopy = (text) => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+ {/* Left: Steps */} +
+

Get Started in 30 Seconds

+

+ Install OmniRoute, configure your providers via web dashboard, and start routing AI + requests. +

+ +
+
+
+ 1 +
+
+

Install OmniRoute

+

+ Run npx command to start the server instantly +

+
+
+ +
+
+ 2 +
+
+

Open Dashboard

+

+ Configure providers and API keys via web interface +

+
+
+ +
+
+ 3 +
+
+

Route Requests

+

+ Point your CLI tools to http://localhost:20128 +

+
+
+
+
+ + {/* Right: Code block */} +
+
+ {/* Terminal header */} +
+
+
+
+
terminal
+
+ + {/* Terminal content */} +
+
handleCopy("npx omniroute")} + > + $ + npx omniroute + + {copied ? "✓ Copied" : "Copy"} + +
+ +
+ > Starting OmniRoute... +
+ > Server running on{" "} + http://localhost:20128 +
+ > Dashboard:{" "} + http://localhost:20128/dashboard +
+ > Ready to route! ✓ +
+ +
+ 📝 Configure providers in dashboard or use environment variables +
+ +
+ Data Location: +
+ macOS/Linux: ~/.omniroute/db.json +
+ Windows: %APPDATA%/omniroute/db.json +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/landing/components/HeroSection.js b/src/app/landing/components/HeroSection.js new file mode 100644 index 0000000000..010b574dee --- /dev/null +++ b/src/app/landing/components/HeroSection.js @@ -0,0 +1,47 @@ +"use client"; + +export default function HeroSection() { + return ( +
+ {/* Glow effect */} +
+ +
+ {/* Version badge */} +
+ + v1.0 is now live +
+ + {/* Main heading */} +

+ One Endpoint for
+ All AI Providers +

+ + {/* Description */} +

+ AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly + with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools. +

+ + {/* CTA Buttons */} +
+ + + code + View on GitHub + +
+
+
+ ); +} diff --git a/src/app/landing/components/HowItWorks.js b/src/app/landing/components/HowItWorks.js new file mode 100644 index 0000000000..f7ba382567 --- /dev/null +++ b/src/app/landing/components/HowItWorks.js @@ -0,0 +1,70 @@ +"use client"; + +export default function HowItWorks() { + return ( +
+
+
+

How OmniRoute Works

+

+ Data flows seamlessly from your application through our intelligent routing layer to the + best provider for the job. +

+
+ +
+ {/* Connection line */} +
+ + {/* Step 1: CLI & SDKs */} +
+
+ terminal +
+
+

1. CLI & SDKs

+

+ Your requests start from your favorite tools or our unified SDK. Just change the + base URL. +

+
+
+ + {/* Step 2: OmniRoute Hub */} +
+
+ + hub + +
+
+

2. OmniRoute Hub

+

+ Our engine analyzes the prompt, checks provider health, and routes for lowest + latency or cost. +

+
+
+ + {/* Step 3: AI Providers */} +
+
+
+
+
+
+
+
+
+
+

3. AI Providers

+

+ The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly. +

+
+
+
+
+
+ ); +} diff --git a/src/app/landing/components/Navigation.js b/src/app/landing/components/Navigation.js new file mode 100644 index 0000000000..4840e290cc --- /dev/null +++ b/src/app/landing/components/Navigation.js @@ -0,0 +1,115 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function Navigation() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const router = useRouter(); + + return ( + + ); +} diff --git a/src/app/landing/page.js b/src/app/landing/page.js new file mode 100644 index 0000000000..db2973272c --- /dev/null +++ b/src/app/landing/page.js @@ -0,0 +1,129 @@ +"use client"; +import { useRouter } from "next/navigation"; +import Navigation from "./components/Navigation"; +import HeroSection from "./components/HeroSection"; +import FlowAnimation from "./components/FlowAnimation"; +import HowItWorks from "./components/HowItWorks"; +import Features from "./components/Features"; +import GetStarted from "./components/GetStarted"; +import Footer from "./components/Footer"; + +export default function LandingPage() { + const router = useRouter(); + return ( +
+ {/* Animated Background */} +
+ {/* Grid pattern */} +
+ + {/* Animated gradient orbs */} +
+
+
+ + {/* Vignette effect */} +
+
+ +
+ + +
+ {/* Hero with Flow Animation */} +
+ +
+ +
+
+ + + + + + {/* CTA Section */} +
+
+
+

+ Ready to Simplify Your AI Infrastructure? +

+

+ Join developers who are streamlining their AI integrations with OmniRoute. Open + source and free to start. +

+
+ + +
+
+
+
+ +
+
+ + {/* Global styles for keyframes */} + +
+ ); +} diff --git a/src/app/layout.js b/src/app/layout.js new file mode 100644 index 0000000000..3918ee72b9 --- /dev/null +++ b/src/app/layout.js @@ -0,0 +1,37 @@ +import { Inter } from "next/font/google"; +import "./globals.css"; +import { ThemeProvider } from "@/shared/components/ThemeProvider"; +import "@/lib/initCloudSync"; // Auto-initialize cloud sync + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +export const metadata = { + title: "OmniRoute - AI Infrastructure Management", + description: + "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.", + icons: { + icon: "/favicon.svg", + }, +}; + +export default function RootLayout({ children }) { + return ( + + + + + {/* eslint-disable-next-line @next/next/no-page-custom-font */} + + + + {children} + + + ); +} diff --git a/src/app/login/page.js b/src/app/login/page.js new file mode 100644 index 0000000000..fb8408fe63 --- /dev/null +++ b/src/app/login/page.js @@ -0,0 +1,119 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input } from "@/shared/components"; +import { useRouter } from "next/navigation"; + +export default function LoginPage() { + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [hasPassword, setHasPassword] = useState(null); + const router = useRouter(); + + useEffect(() => { + async function checkAuth() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; + + try { + const res = await fetch(`${baseUrl}/api/settings`, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (res.ok) { + const data = await res.json(); + if (data.requireLogin === false) { + router.push("/dashboard"); + router.refresh(); + return; + } + setHasPassword(!!data.hasPassword); + } else { + // Safe fallback on non-OK response to avoid infinite loading state. + setHasPassword(true); + } + } catch (err) { + clearTimeout(timeoutId); + setHasPassword(true); + } + } + checkAuth(); + }, [router]); + + const handleLogin = async (e) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + if (res.ok) { + router.push("/dashboard"); + router.refresh(); + } else { + const data = await res.json(); + setError(data.error || "Invalid password"); + } + } catch (err) { + setError("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + + // Show loading state while checking password + if (hasPassword === null) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+

OmniRoute

+

Enter your password to access the dashboard

+
+ + +
+
+ + setPassword(e.target.value)} + required + autoFocus + /> + {error &&

{error}

} +
+ + + +

+ Default password is 123456 +

+
+
+
+
+ ); +} diff --git a/src/app/page.js b/src/app/page.js new file mode 100644 index 0000000000..53ac0287f7 --- /dev/null +++ b/src/app/page.js @@ -0,0 +1,7 @@ +// Auto-initialize cloud sync when server starts +import "@/lib/initCloudSync"; +import { redirect } from "next/navigation"; + +export default function InitPage() { + redirect("/dashboard"); +} diff --git a/src/app/privacy/page.js b/src/app/privacy/page.js new file mode 100644 index 0000000000..5beb3e3779 --- /dev/null +++ b/src/app/privacy/page.js @@ -0,0 +1,161 @@ +import Link from "next/link"; + +export const metadata = { + title: "Privacy Policy | OmniRoute", + description: "Privacy policy for the OmniRoute AI API proxy router.", +}; + +export default function PrivacyPage() { + return ( +
+
+ + arrow_back + Back to home + + +

Privacy Policy

+

Last updated: February 13, 2026

+ +
+
+

+ 1. Local-First Architecture +

+

+ OmniRoute is designed as a local-first{" "} + application. All data processing and storage occurs entirely on your machine. There is + no centralized server collecting your information. +

+
+ +
+

2. Data We Store

+

+ The following data is stored locally in{" "} + ~/.omniroute/storage.sqlite: +

+
    +
  • + Provider configurations — connection + URLs, provider types, and priority settings +
  • +
  • + API keys — encrypted and stored locally + for authenticating with AI providers +
  • +
  • + Usage logs — request counts, token + usage, model names, timestamps, and response times +
  • +
  • + Application settings — theme + preferences, routing strategy, and combo configurations +
  • +
+
+ +
+

3. No Telemetry

+

+ OmniRoute does not collect telemetry, + analytics, or crash reports. No data is sent to us or any third party. Your usage + patterns, API calls, and configurations remain entirely private. +

+
+ +
+

+ 4. Third-Party AI Providers +

+

+ When you make API calls through OmniRoute, your requests are forwarded to the AI + providers you have configured (e.g., OpenAI, Anthropic, Google). These providers have + their own privacy policies that govern how they handle your data. Please review: +

+ +
+ +
+

5. Cloud Sync (Optional)

+

+ If you enable the optional cloud sync feature, provider configurations and API keys + may be transmitted to a configured cloud endpoint. This feature is{" "} + disabled by default and requires explicit + opt-in. +

+
+ +
+

6. Logging

+

Request logs can be configured through the dashboard settings. You can:

+
    +
  • View and export usage analytics
  • +
  • Clear usage history at any time
  • +
  • Configure log retention policies
  • +
  • Back up and restore your database
  • +
+
+ +
+

7. Your Rights

+

+ Since all data is stored locally, you have full control. You can delete your data at + any time by removing the ~/.omniroute/{" "} + directory or using the database backup/restore features in the dashboard. +

+
+
+ +
+

+ Questions? Visit our{" "} + + GitHub repository + + . +

+
+
+
+ ); +} diff --git a/src/app/terms/page.js b/src/app/terms/page.js new file mode 100644 index 0000000000..a1a9970f11 --- /dev/null +++ b/src/app/terms/page.js @@ -0,0 +1,113 @@ +import Link from "next/link"; + +export const metadata = { + title: "Terms of Service | OmniRoute", + description: "Terms of service for the OmniRoute AI API proxy router.", +}; + +export default function TermsPage() { + return ( +
+
+ + arrow_back + Back to home + + +

Terms of Service

+

Last updated: February 13, 2026

+ +
+
+

1. Overview

+

+ OmniRoute is a local-first AI API proxy + router that operates entirely on your machine. It routes requests to multiple AI + providers with load balancing, failover, and usage tracking. +

+
+ +
+

2. User Responsibilities

+
    +
  • + You are solely responsible for managing your own API keys and credentials for + third-party AI providers (OpenAI, Anthropic, Google, etc.). +
  • +
  • + You must comply with the terms of service of each AI provider whose API you access + through OmniRoute. +
  • +
  • + You are responsible for the security of your local OmniRoute installation, including + setting a password and restricting network access. +
  • +
+
+ +
+

3. How It Works

+

+ OmniRoute acts as an intermediary proxy. API calls sent to OmniRoute are translated + and forwarded to your configured AI providers. OmniRoute does not modify the content + of your requests or responses beyond the necessary protocol translation. +

+
+ +
+

4. Data Handling

+
    +
  • + All data is stored locally on your + machine in a SQLite database. +
  • +
  • + OmniRoute does not transmit any data to external servers unless you explicitly + enable cloud sync features. +
  • +
  • + Usage logs, API keys, and configuration are stored in{" "} + ~/.omniroute/. +
  • +
+
+ +
+

5. Disclaimer

+

+ OmniRoute is provided “as is” without warranty of any kind. We are not + responsible for any costs incurred through API usage, service disruptions, or data + loss. Always maintain backups of your configuration. +

+
+ +
+

6. Open Source

+

+ OmniRoute is open-source software. You are free to inspect, modify, and distribute it + under the terms of its license. +

+
+
+ +
+

+ Questions? Visit our{" "} + + GitHub repository + + . +

+
+
+
+ ); +} diff --git a/src/lib/cloudSync.js b/src/lib/cloudSync.js new file mode 100644 index 0000000000..99eec2e508 --- /dev/null +++ b/src/lib/cloudSync.js @@ -0,0 +1,126 @@ +import { + getProviderConnections, + getModelAliases, + getCombos, + getApiKeys, + updateProviderConnection, +} from "@/lib/localDb"; + +const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL; +const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000); + +export async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Sync data to Cloud (shared utility) + * @param {string} machineId + * @param {string|null} createdKey - Key created during enable + */ +export async function syncToCloud(machineId, createdKey = null) { + if (!CLOUD_URL) { + return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" }; + } + + // Get current data from db + const providers = await getProviderConnections(); + const modelAliases = await getModelAliases(); + const combos = await getCombos(); + const apiKeys = await getApiKeys(); + + let response; + try { + // Send to Cloud + response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providers, + modelAliases, + combos, + apiKeys, + }), + }); + } catch (error) { + const isTimeout = error?.name === "AbortError"; + return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" }; + } + + if (!response.ok) { + const errorText = await response.text(); + console.log("Cloud sync failed:", errorText); + return { error: "Cloud sync failed" }; + } + + const result = await response.json(); + + // Update local db with tokens from Cloud (providers stored by ID) + if (result.data && result.data.providers) { + await updateLocalTokens(result.data.providers); + } + + const responseData = { + success: true, + message: "Synced successfully", + changes: result.changes, + }; + + if (createdKey) { + responseData.createdKey = createdKey; + } + + return responseData; +} + +/** + * Update local db with data from Cloud + * Simple logic: if Cloud is newer, sync entire provider + * cloudProviders is object keyed by provider ID + */ +async function updateLocalTokens(cloudProviders) { + const localProviders = await getProviderConnections(); + + for (const localProvider of localProviders) { + const cloudProvider = cloudProviders[localProvider.id]; + if (!cloudProvider) continue; + + const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime(); + const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime(); + + // Simple logic: if Cloud is newer, sync entire provider + if (cloudUpdatedAt > localUpdatedAt) { + const updates = { + // Tokens + accessToken: cloudProvider.accessToken, + refreshToken: cloudProvider.refreshToken, + expiresAt: cloudProvider.expiresAt, + expiresIn: cloudProvider.expiresIn, + + // Provider specific data + providerSpecificData: + cloudProvider.providerSpecificData || localProvider.providerSpecificData, + + // Status fields + testStatus: cloudProvider.status || "active", + lastError: cloudProvider.lastError, + lastErrorAt: cloudProvider.lastErrorAt, + errorCode: cloudProvider.errorCode, + rateLimitedUntil: cloudProvider.rateLimitedUntil, + + // Metadata + updatedAt: cloudProvider.updatedAt, + }; + + await updateProviderConnection(localProvider.id, updates); + } + } +} + +export { CLOUD_URL, CLOUD_SYNC_TIMEOUT_MS }; diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js new file mode 100644 index 0000000000..4f9619e018 --- /dev/null +++ b/src/lib/dataPaths.js @@ -0,0 +1,61 @@ +import path from "node:path"; +import os from "node:os"; + +export const APP_NAME = "omniroute"; + +function safeHomeDir() { + try { + return os.homedir(); + } catch { + return process.cwd(); + } +} + +function normalizeConfiguredPath(dir) { + if (typeof dir !== "string") return null; + const trimmed = dir.trim(); + if (!trimmed) return null; + return path.resolve(trimmed); +} + +export function getLegacyDotDataDir() { + return path.join(safeHomeDir(), `.${APP_NAME}`); +} + +export function getDefaultDataDir() { + const homeDir = safeHomeDir(); + + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); + return path.join(appData, APP_NAME); + } + + // Support XDG on Linux/macOS when explicitly configured. + const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); + if (xdgConfigHome) { + return path.join(xdgConfigHome, APP_NAME); + } + + return getLegacyDotDataDir(); +} + +export function resolveDataDir({ isCloud = false } = {}) { + if (isCloud) return "/tmp"; + + const configured = normalizeConfiguredPath(process.env.DATA_DIR); + if (configured) return configured; + + return getDefaultDataDir(); +} + +export function isSamePath(a, b) { + if (!a || !b) return false; + const normalizedA = path.resolve(a); + const normalizedB = path.resolve(b); + + if (process.platform === "win32") { + return normalizedA.toLowerCase() === normalizedB.toLowerCase(); + } + + return normalizedA === normalizedB; +} diff --git a/src/lib/db/apiKeys.js b/src/lib/db/apiKeys.js new file mode 100644 index 0000000000..307ff4f3af --- /dev/null +++ b/src/lib/db/apiKeys.js @@ -0,0 +1,61 @@ +/** + * db/apiKeys.js — API key management. + */ + +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance, rowToCamel } from "./core.js"; +import { backupDbFile } from "./backup.js"; + +export async function getApiKeys() { + const db = getDbInstance(); + return db.prepare("SELECT * FROM api_keys ORDER BY created_at").all().map(rowToCamel); +} + +export async function createApiKey(name, machineId) { + if (!machineId) { + throw new Error("machineId is required"); + } + + const db = getDbInstance(); + const now = new Date().toISOString(); + + const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); + const result = generateApiKeyWithMachine(machineId); + + const apiKey = { + id: uuidv4(), + name: name, + key: result.key, + machineId: machineId, + createdAt: now, + }; + + db.prepare( + "INSERT INTO api_keys (id, name, key, machine_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, apiKey.createdAt); + + backupDbFile("pre-write"); + return apiKey; +} + +export async function deleteApiKey(id) { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM api_keys WHERE id = ?").run(id); + if (result.changes === 0) return false; + backupDbFile("pre-write"); + return true; +} + +export async function validateApiKey(key) { + const db = getDbInstance(); + const row = db.prepare("SELECT 1 FROM api_keys WHERE key = ?").get(key); + return !!row; +} + +export async function getApiKeyMetadata(key) { + if (!key) return null; + const db = getDbInstance(); + const row = db.prepare("SELECT id, name, machine_id FROM api_keys WHERE key = ?").get(key); + if (!row) return null; + return { id: row.id, name: row.name, machineId: row.machine_id }; +} diff --git a/src/lib/db/backup.js b/src/lib/db/backup.js new file mode 100644 index 0000000000..e2ff9118f3 --- /dev/null +++ b/src/lib/db/backup.js @@ -0,0 +1,218 @@ +/** + * db/backup.js — Database backup/restore operations. + */ + +import Database from "better-sqlite3"; +import path from "node:path"; +import fs from "node:fs"; +import { + getDbInstance, + resetDbInstance, + isBuildPhase, + isCloud, + SQLITE_FILE, + DB_BACKUPS_DIR, + DATA_DIR, +} from "./core.js"; + +// ──────────────── Backup Config ──────────────── + +let _lastBackupAt = 0; +const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes +const MAX_DB_BACKUPS = 20; + +// ──────────────── Backup ──────────────── + +export function backupDbFile(reason = "auto") { + try { + if (isBuildPhase || isCloud) return null; + if (!SQLITE_FILE || !fs.existsSync(SQLITE_FILE)) return null; + + const stat = fs.statSync(SQLITE_FILE); + if (stat.size < 4096) { + console.warn(`[DB] Backup SKIPPED — DB too small (${stat.size}B)`); + return null; + } + + // Throttle + const now = Date.now(); + if (reason !== "manual" && reason !== "pre-restore" && now - _lastBackupAt < BACKUP_THROTTLE_MS) + return null; + _lastBackupAt = now; + + const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); + + // Shrink check vs latest backup + const existingBackups = fs + .readdirSync(backupDir) + .filter((f) => f.startsWith("db_") && f.endsWith(".sqlite")) + .sort(); + if (existingBackups.length > 0) { + const latestBackup = existingBackups[existingBackups.length - 1]; + const latestStat = fs.statSync(path.join(backupDir, latestBackup)); + if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) { + console.warn(`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`); + return null; + } + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupFile = path.join(backupDir, `db_${timestamp}_${reason}.sqlite`); + + // Use native SQLite backup API for consistency + const db = getDbInstance(); + db.backup(backupFile) + .then(() => { + console.log(`[DB] Backup created: ${backupFile} (${stat.size} bytes)`); + }) + .catch((err) => { + console.error("[DB] Backup failed:", err.message); + }); + + // Rotation — keep only last N, delete smallest first + const files = fs + .readdirSync(backupDir) + .filter((f) => f.startsWith("db_") && f.endsWith(".sqlite")) + .sort(); + while (files.length > MAX_DB_BACKUPS) { + let smallestIdx = 0; + let smallestSize = Infinity; + for (let i = 0; i < files.length - 1; i++) { + try { + const fStat = fs.statSync(path.join(backupDir, files[i])); + if (fStat.size < smallestSize) { + smallestSize = fStat.size; + smallestIdx = i; + } + } catch { + smallestIdx = i; + break; + } + } + try { + fs.unlinkSync(path.join(backupDir, files[smallestIdx])); + } catch { + /* gone */ + } + files.splice(smallestIdx, 1); + } + + return { filename: path.basename(backupFile), size: stat.size }; + } catch (err) { + console.error("[DB] Backup failed:", err.message); + return null; + } +} + +// ──────────────── List Backups ──────────────── + +export async function listDbBackups() { + const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + try { + if (!fs.existsSync(backupDir)) return []; + + const entries = fs + .readdirSync(backupDir) + .filter((f) => f.startsWith("db_") && f.endsWith(".sqlite")) + .sort() + .reverse(); + + return entries.map((filename) => { + const filePath = path.join(backupDir, filename); + const stat = fs.statSync(filePath); + const match = filename.match(/^db_(.+?)_([^.]+)\.sqlite$/); + const reason = match ? match[2] : "unknown"; + + let connectionCount = 0; + try { + const backupDb = new Database(filePath, { readonly: true }); + const row = backupDb.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get(); + connectionCount = row?.cnt || 0; + backupDb.close(); + } catch { + /* ignore */ + } + + return { + id: filename, + filename, + createdAt: stat.mtime.toISOString(), + size: stat.size, + reason, + connectionCount, + }; + }); + } catch { + return []; + } +} + +// ──────────────── Restore Backup ──────────────── + +export async function restoreDbBackup(backupId) { + const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups"); + const backupPath = path.join(backupDir, backupId); + + if (!backupId.startsWith("db_") || !backupId.endsWith(".sqlite")) { + throw new Error("Invalid backup ID"); + } + if (!fs.existsSync(backupPath)) { + throw new Error(`Backup not found: ${backupId}`); + } + + // Validate backup integrity + try { + const testDb = new Database(backupPath, { readonly: true }); + const result = testDb.pragma("integrity_check"); + testDb.close(); + if (result[0]?.integrity_check !== "ok") { + throw new Error("Backup integrity check failed"); + } + } catch (e) { + if (e.message === "Backup integrity check failed") throw e; + throw new Error(`Backup file is corrupt: ${e.message}`); + } + + // Force pre-restore backup (bypass throttle) + _lastBackupAt = 0; + backupDbFile("pre-restore"); + + // Close and reset current connection + resetDbInstance(); + + // Remove main file and WAL sidecars to avoid stale frame replay after restore. + const sqliteFilesToReplace = [ + SQLITE_FILE, + `${SQLITE_FILE}-wal`, + `${SQLITE_FILE}-shm`, + `${SQLITE_FILE}-journal`, + ]; + for (const filePath of sqliteFilesToReplace) { + if (!filePath) continue; + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } + + // Copy backup over current DB + fs.copyFileSync(backupPath, SQLITE_FILE); + + // Reopen + const db = getDbInstance(); + const connCount = db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get()?.cnt || 0; + const nodeCount = db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get()?.cnt || 0; + const comboCount = db.prepare("SELECT COUNT(*) as cnt FROM combos").get()?.cnt || 0; + const keyCount = db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get()?.cnt || 0; + + console.log(`[DB] Restored backup: ${backupId} (${connCount} connections)`); + + return { + restored: true, + backupId, + connectionCount: connCount, + nodeCount, + comboCount, + apiKeyCount: keyCount, + }; +} diff --git a/src/lib/db/combos.js b/src/lib/db/combos.js new file mode 100644 index 0000000000..8e434eb0f7 --- /dev/null +++ b/src/lib/db/combos.js @@ -0,0 +1,76 @@ +/** + * db/combos.js — Combo CRUD operations. + */ + +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance } from "./core.js"; +import { backupDbFile } from "./backup.js"; + +export async function getCombos() { + const db = getDbInstance(); + return db + .prepare("SELECT data FROM combos ORDER BY name") + .all() + .map((r) => JSON.parse(r.data)); +} + +export async function getComboById(id) { + const db = getDbInstance(); + const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); + return row ? JSON.parse(row.data) : null; +} + +export async function getComboByName(name) { + const db = getDbInstance(); + const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name); + return row ? JSON.parse(row.data) : null; +} + +export async function createCombo(data) { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const combo = { + id: uuidv4(), + name: data.name, + models: data.models || [], + strategy: data.strategy || "priority", + config: data.config || {}, + createdAt: now, + updatedAt: now, + }; + + db.prepare( + "INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + ).run(combo.id, combo.name, JSON.stringify(combo), now, now); + + backupDbFile("pre-write"); + return combo; +} + +export async function updateCombo(id, data) { + const db = getDbInstance(); + const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); + if (!existing) return null; + + const current = JSON.parse(existing.data); + const merged = { ...current, ...data, updatedAt: new Date().toISOString() }; + + db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run( + merged.name, + JSON.stringify(merged), + merged.updatedAt, + id + ); + + backupDbFile("pre-write"); + return merged; +} + +export async function deleteCombo(id) { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); + if (result.changes === 0) return false; + backupDbFile("pre-write"); + return true; +} diff --git a/src/lib/db/core.js b/src/lib/db/core.js new file mode 100644 index 0000000000..58ac05ace2 --- /dev/null +++ b/src/lib/db/core.js @@ -0,0 +1,490 @@ +/** + * db/core.js — Database infrastructure: schema, singleton, utils, migration. + * + * All domain modules import `getDbInstance` and helpers from here. + */ + +import Database from "better-sqlite3"; +import path from "node:path"; +import fs from "node:fs"; +import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths.js"; + +// ──────────────── Environment Detection ──────────────── + +export const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null; + +export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; + +// ──────────────── Paths ──────────────── + +export const DATA_DIR = resolveDataDir({ isCloud }); +const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); +export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite"); +const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); +export const DB_BACKUPS_DIR = isCloud ? null : path.join(DATA_DIR, "db_backups"); + +// Ensure data directory exists +if (!isCloud && !fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +// ──────────────── Schema ──────────────── + +const SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + auth_type TEXT, + name TEXT, + email TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + access_token TEXT, + refresh_token TEXT, + expires_at TEXT, + token_expires_at TEXT, + scope TEXT, + project_id TEXT, + test_status TEXT, + error_code TEXT, + last_error TEXT, + last_error_at TEXT, + last_error_type TEXT, + last_error_source TEXT, + backoff_level INTEGER DEFAULT 0, + rate_limited_until TEXT, + health_check_interval INTEGER, + last_health_check_at TEXT, + last_tested TEXT, + api_key TEXT, + id_token TEXT, + provider_specific_data TEXT, + expires_in INTEGER, + display_name TEXT, + global_priority INTEGER, + default_model TEXT, + token_type TEXT, + consecutive_use_count INTEGER DEFAULT 0, + rate_limit_protection INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider); + CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active); + CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority); + + CREATE TABLE IF NOT EXISTS provider_nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + prefix TEXT, + api_type TEXT, + base_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS key_value ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) + ); + + CREATE TABLE IF NOT EXISTS combos ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + machine_id TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ak_key ON api_keys(key); + + CREATE TABLE IF NOT EXISTS db_meta ( + key TEXT PRIMARY KEY, + value TEXT + ); + + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + model TEXT, + connection_id TEXT, + api_key_id TEXT, + api_key_name TEXT, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT 0, + tokens_cache_creation INTEGER DEFAULT 0, + tokens_reasoning INTEGER DEFAULT 0, + status TEXT, + timestamp TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp); + CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider); + CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model); + + CREATE TABLE IF NOT EXISTS call_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + method TEXT, + path TEXT, + status INTEGER, + model TEXT, + provider TEXT, + account TEXT, + connection_id TEXT, + duration INTEGER DEFAULT 0, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + source_format TEXT, + target_format TEXT, + api_key_id TEXT, + api_key_name TEXT, + combo_name TEXT, + request_body TEXT, + response_body TEXT, + error TEXT + ); + CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); + CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); +`; + +// ──────────────── Column Mapping ──────────────── + +export function toSnakeCase(str) { + return str.replace(/([A-Z])/g, "_$1").toLowerCase(); +} + +export function toCamelCase(str) { + return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +} + +export function objToSnake(obj) { + if (!obj || typeof obj !== "object") return obj; + const result = {}; + for (const [k, v] of Object.entries(obj)) { + result[toSnakeCase(k)] = v; + } + return result; +} + +export function rowToCamel(row) { + if (!row) return null; + const result = {}; + for (const [k, v] of Object.entries(row)) { + const camelKey = toCamelCase(k); + if (camelKey === "isActive" || camelKey === "rateLimitProtection") { + result[camelKey] = v === 1 || v === true; + } else if (camelKey === "providerSpecificData" && typeof v === "string") { + try { + result[camelKey] = JSON.parse(v); + } catch { + result[camelKey] = v; + } + } else { + result[camelKey] = v; + } + } + return result; +} + +export function cleanNulls(obj) { + const result = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + result[k] = v; + } + } + return result; +} + +// ──────────────── Singleton DB Instance ──────────────── + +let _db = null; + +function ensureProviderConnectionsColumns(db) { + try { + const columns = db.prepare("PRAGMA table_info(provider_connections)").all(); + const columnNames = new Set(columns.map((column) => column.name)); + if (!columnNames.has("rate_limit_protection")) { + db.exec( + "ALTER TABLE provider_connections ADD COLUMN rate_limit_protection INTEGER DEFAULT 0" + ); + console.log("[DB] Added provider_connections.rate_limit_protection column"); + } + } catch (error) { + console.warn("[DB] Failed to verify provider_connections schema:", error.message); + } +} + +export function getDbInstance() { + if (_db) return _db; + + if (isCloud || isBuildPhase) { + if (isBuildPhase) { + console.log("[DB] Build phase detected — using in-memory SQLite (read-only)"); + } + _db = new Database(":memory:"); + _db.pragma("journal_mode = WAL"); + _db.exec(SCHEMA_SQL); + return _db; + } + + // Detect and replace old incompatible schema + if (fs.existsSync(SQLITE_FILE)) { + try { + const probe = new Database(SQLITE_FILE, { readonly: true }); + const hasOldSchema = probe + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'") + .get(); + probe.close(); + + if (hasOldSchema) { + const oldPath = SQLITE_FILE + ".old-schema"; + console.log( + `[DB] Old incompatible schema detected — renaming to ${path.basename(oldPath)}` + ); + fs.renameSync(SQLITE_FILE, oldPath); + for (const ext of ["-wal", "-shm"]) { + try { + if (fs.existsSync(SQLITE_FILE + ext)) fs.unlinkSync(SQLITE_FILE + ext); + } catch { + /* ok */ + } + } + } + } catch (e) { + console.warn("[DB] Could not probe existing DB, will create fresh:", e.message); + try { + fs.unlinkSync(SQLITE_FILE); + } catch { + /* ok */ + } + } + } + + _db = new Database(SQLITE_FILE); + _db.pragma("journal_mode = WAL"); + _db.pragma("busy_timeout = 5000"); + _db.pragma("synchronous = NORMAL"); + _db.exec(SCHEMA_SQL); + ensureProviderConnectionsColumns(_db); + + // Auto-migrate from db.json if exists + if (JSON_DB_FILE && fs.existsSync(JSON_DB_FILE)) { + migrateFromJson(_db, JSON_DB_FILE); + } + + // Store schema version + const versionStmt = _db.prepare( + "INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', '1')" + ); + versionStmt.run(); + + console.log(`[DB] SQLite database ready: ${SQLITE_FILE}`); + return _db; +} + +/** + * Reset the singleton (used by restore). + */ +export function resetDbInstance() { + if (_db) { + _db.close(); + _db = null; + } +} + +// ──────────────── JSON → SQLite Migration ──────────────── + +function migrateFromJson(db, jsonPath) { + try { + const raw = fs.readFileSync(jsonPath, "utf-8"); + const data = JSON.parse(raw); + + const connCount = (data.providerConnections || []).length; + const nodeCount = (data.providerNodes || []).length; + const keyCount = (data.apiKeys || []).length; + + if (connCount === 0 && nodeCount === 0 && keyCount === 0) { + console.log("[DB] db.json has no data to migrate, skipping"); + fs.renameSync(jsonPath, jsonPath + ".empty"); + return; + } + + console.log( + `[DB] Migrating db.json → SQLite (${connCount} connections, ${nodeCount} nodes, ${keyCount} keys)...` + ); + + const migrate = db.transaction(() => { + // 1. Provider Connections + const insertConn = db.prepare(` + INSERT OR REPLACE INTO provider_connections ( + id, provider, auth_type, name, email, priority, is_active, + access_token, refresh_token, expires_at, token_expires_at, + scope, project_id, test_status, error_code, last_error, + last_error_at, last_error_type, last_error_source, backoff_level, + rate_limited_until, health_check_interval, last_health_check_at, + last_tested, api_key, id_token, provider_specific_data, + expires_in, display_name, global_priority, default_model, + token_type, consecutive_use_count, rate_limit_protection, created_at, updated_at + ) VALUES ( + @id, @provider, @authType, @name, @email, @priority, @isActive, + @accessToken, @refreshToken, @expiresAt, @tokenExpiresAt, + @scope, @projectId, @testStatus, @errorCode, @lastError, + @lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel, + @rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt, + @lastTested, @apiKey, @idToken, @providerSpecificData, + @expiresIn, @displayName, @globalPriority, @defaultModel, + @tokenType, @consecutiveUseCount, @rateLimitProtection, @createdAt, @updatedAt + ) + `); + + for (const conn of data.providerConnections || []) { + insertConn.run({ + id: conn.id, + provider: conn.provider, + authType: conn.authType || "oauth", + name: conn.name || null, + email: conn.email || null, + priority: conn.priority || 0, + isActive: conn.isActive === false ? 0 : 1, + accessToken: conn.accessToken || null, + refreshToken: conn.refreshToken || null, + expiresAt: conn.expiresAt || null, + tokenExpiresAt: conn.tokenExpiresAt || null, + scope: conn.scope || null, + projectId: conn.projectId || null, + testStatus: conn.testStatus || null, + errorCode: conn.errorCode || null, + lastError: conn.lastError || null, + lastErrorAt: conn.lastErrorAt || null, + lastErrorType: conn.lastErrorType || null, + lastErrorSource: conn.lastErrorSource || null, + backoffLevel: conn.backoffLevel || 0, + rateLimitedUntil: conn.rateLimitedUntil || null, + healthCheckInterval: conn.healthCheckInterval || null, + lastHealthCheckAt: conn.lastHealthCheckAt || null, + lastTested: conn.lastTested || null, + apiKey: conn.apiKey || null, + idToken: conn.idToken || null, + providerSpecificData: conn.providerSpecificData + ? JSON.stringify(conn.providerSpecificData) + : null, + expiresIn: conn.expiresIn || null, + displayName: conn.displayName || null, + globalPriority: conn.globalPriority || null, + defaultModel: conn.defaultModel || null, + tokenType: conn.tokenType || null, + consecutiveUseCount: conn.consecutiveUseCount || 0, + rateLimitProtection: + conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0, + createdAt: conn.createdAt || new Date().toISOString(), + updatedAt: conn.updatedAt || new Date().toISOString(), + }); + } + + // 2. Provider Nodes + const insertNode = db.prepare(` + INSERT OR REPLACE INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt) + `); + for (const node of data.providerNodes || []) { + insertNode.run({ + id: node.id, + type: node.type, + name: node.name, + prefix: node.prefix || null, + apiType: node.apiType || null, + baseUrl: node.baseUrl || null, + createdAt: node.createdAt || new Date().toISOString(), + updatedAt: node.updatedAt || new Date().toISOString(), + }); + } + + // 3. Key-Value pairs + const insertKv = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ); + + for (const [alias, model] of Object.entries(data.modelAliases || {})) { + insertKv.run("modelAliases", alias, JSON.stringify(model)); + } + for (const [toolName, mappings] of Object.entries(data.mitmAlias || {})) { + insertKv.run("mitmAlias", toolName, JSON.stringify(mappings)); + } + for (const [key, value] of Object.entries(data.settings || {})) { + insertKv.run("settings", key, JSON.stringify(value)); + } + for (const [provider, models] of Object.entries(data.pricing || {})) { + insertKv.run("pricing", provider, JSON.stringify(models)); + } + for (const [providerId, models] of Object.entries(data.customModels || {})) { + insertKv.run("customModels", providerId, JSON.stringify(models)); + } + if (data.proxyConfig) { + insertKv.run("proxyConfig", "global", JSON.stringify(data.proxyConfig.global || null)); + insertKv.run("proxyConfig", "providers", JSON.stringify(data.proxyConfig.providers || {})); + insertKv.run("proxyConfig", "combos", JSON.stringify(data.proxyConfig.combos || {})); + insertKv.run("proxyConfig", "keys", JSON.stringify(data.proxyConfig.keys || {})); + } + + // 4. Combos + const insertCombo = db.prepare(` + INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at) + VALUES (@id, @name, @data, @createdAt, @updatedAt) + `); + for (const combo of data.combos || []) { + insertCombo.run({ + id: combo.id, + name: combo.name, + data: JSON.stringify(combo), + createdAt: combo.createdAt || new Date().toISOString(), + updatedAt: combo.updatedAt || new Date().toISOString(), + }); + } + + // 5. API Keys + const insertKey = db.prepare(` + INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, created_at) + VALUES (@id, @name, @key, @machineId, @createdAt) + `); + for (const apiKey of data.apiKeys || []) { + insertKey.run({ + id: apiKey.id, + name: apiKey.name, + key: apiKey.key, + machineId: apiKey.machineId || null, + createdAt: apiKey.createdAt || new Date().toISOString(), + }); + } + }); + + migrate(); + + const migratedPath = jsonPath + ".migrated"; + fs.renameSync(jsonPath, migratedPath); + console.log(`[DB] ✓ Migration complete. Original saved as ${migratedPath}`); + + const legacyBackupDir = path.join(DATA_DIR, "db_backups"); + if (fs.existsSync(legacyBackupDir)) { + const jsonBackups = fs.readdirSync(legacyBackupDir).filter((f) => f.endsWith(".json")); + if (jsonBackups.length > 0) { + console.log( + `[DB] Note: ${jsonBackups.length} legacy .json backups remain in ${legacyBackupDir}` + ); + } + } + } catch (err) { + console.error("[DB] Migration from db.json failed:", err.message); + } +} diff --git a/src/lib/db/models.js b/src/lib/db/models.js new file mode 100644 index 0000000000..932c9eb1e6 --- /dev/null +++ b/src/lib/db/models.js @@ -0,0 +1,135 @@ +/** + * db/models.js — Model aliases, MITM aliases, and custom models. + */ + +import { getDbInstance } from "./core.js"; +import { backupDbFile } from "./backup.js"; + +// ──────────────── Model Aliases ──────────────── + +export async function getModelAliases() { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'modelAliases'") + .all(); + const result = {}; + for (const row of rows) { + result[row.key] = JSON.parse(row.value); + } + return result; +} + +export async function setModelAlias(alias, model) { + const db = getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)" + ).run(alias, JSON.stringify(model)); + backupDbFile("pre-write"); +} + +export async function deleteModelAlias(alias) { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias); + backupDbFile("pre-write"); +} + +// ──────────────── MITM Alias ──────────────── + +export async function getMitmAlias(toolName) { + const db = getDbInstance(); + if (toolName) { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'mitmAlias' AND key = ?") + .get(toolName); + return row ? JSON.parse(row.value) : {}; + } + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'mitmAlias'").all(); + const result = {}; + for (const row of rows) { + result[row.key] = JSON.parse(row.value); + } + return result; +} + +export async function setMitmAliasAll(toolName, mappings) { + const db = getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('mitmAlias', ?, ?)" + ).run(toolName, JSON.stringify(mappings || {})); + backupDbFile("pre-write"); +} + +// ──────────────── Custom Models ──────────────── + +export async function getCustomModels(providerId) { + const db = getDbInstance(); + if (providerId) { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") + .get(providerId); + return row ? JSON.parse(row.value) : []; + } + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") + .all(); + const result = {}; + for (const row of rows) result[row.key] = JSON.parse(row.value); + return result; +} + +export async function getAllCustomModels() { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") + .all(); + const result = {}; + for (const row of rows) result[row.key] = JSON.parse(row.value); + return result; +} + +export async function addCustomModel(providerId, modelId, modelName) { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") + .get(providerId); + const models = row ? JSON.parse(row.value) : []; + + const exists = models.find((m) => m.id === modelId); + if (exists) return exists; + + const model = { id: modelId, name: modelName || modelId }; + models.push(model); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" + ).run(providerId, JSON.stringify(models)); + backupDbFile("pre-write"); + return model; +} + +export async function removeCustomModel(providerId, modelId) { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") + .get(providerId); + if (!row) return false; + + const models = JSON.parse(row.value); + const before = models.length; + const filtered = models.filter((m) => m.id !== modelId); + + if (filtered.length === before) return false; + + if (filtered.length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run( + providerId + ); + } else { + db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( + JSON.stringify(filtered), + providerId + ); + } + + backupDbFile("pre-write"); + return true; +} diff --git a/src/lib/db/providers.js b/src/lib/db/providers.js new file mode 100644 index 0000000000..2b832169e3 --- /dev/null +++ b/src/lib/db/providers.js @@ -0,0 +1,411 @@ +/** + * db/providers.js — Provider connections and nodes CRUD. + */ + +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance, rowToCamel, cleanNulls } from "./core.js"; +import { backupDbFile } from "./backup.js"; + +// ──────────────── Provider Connections ──────────────── + +export async function getProviderConnections(filter = {}) { + const db = getDbInstance(); + let sql = "SELECT * FROM provider_connections"; + const conditions = []; + const params = {}; + + if (filter.provider) { + conditions.push("provider = @provider"); + params.provider = filter.provider; + } + if (filter.isActive !== undefined) { + conditions.push("is_active = @isActive"); + params.isActive = filter.isActive ? 1 : 0; + } + + if (conditions.length > 0) { + sql += " WHERE " + conditions.join(" AND "); + } + sql += " ORDER BY priority ASC, updated_at DESC"; + + const rows = db.prepare(sql).all(params); + return rows.map((r) => cleanNulls(rowToCamel(r))); +} + +export async function getProviderConnectionById(id) { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); + return row ? cleanNulls(rowToCamel(row)) : null; +} + +export async function createProviderConnection(data) { + const db = getDbInstance(); + const now = new Date().toISOString(); + + // Upsert check + let existing = null; + if (data.authType === "oauth" && data.email) { + existing = db + .prepare( + "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?" + ) + .get(data.provider, data.email); + } else if (data.authType === "apikey" && data.name) { + existing = db + .prepare( + "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?" + ) + .get(data.provider, data.name); + } + + if (existing) { + const merged = { ...rowToCamel(existing), ...data, updatedAt: now }; + _updateConnectionRow(db, existing.id, merged); + backupDbFile("pre-write"); + return cleanNulls(merged); + } + + // Generate name + let connectionName = data.name || null; + if (!connectionName && data.authType === "oauth") { + if (data.email) { + connectionName = data.email; + } else { + const count = + db + .prepare("SELECT COUNT(*) as cnt FROM provider_connections WHERE provider = ?") + .get(data.provider)?.cnt || 0; + connectionName = `Account ${count + 1}`; + } + } + + // Auto-increment priority + let connectionPriority = data.priority; + if (!connectionPriority) { + const max = db + .prepare("SELECT MAX(priority) as maxP FROM provider_connections WHERE provider = ?") + .get(data.provider); + connectionPriority = (max?.maxP || 0) + 1; + } + + const connection = { + id: uuidv4(), + provider: data.provider, + authType: data.authType || "oauth", + name: connectionName, + priority: connectionPriority, + isActive: data.isActive !== undefined ? data.isActive : true, + createdAt: now, + updatedAt: now, + }; + + // Optional fields + const optionalFields = [ + "displayName", + "email", + "globalPriority", + "defaultModel", + "accessToken", + "refreshToken", + "expiresAt", + "tokenType", + "scope", + "idToken", + "projectId", + "apiKey", + "testStatus", + "lastTested", + "lastError", + "lastErrorAt", + "lastErrorType", + "lastErrorSource", + "rateLimitedUntil", + "expiresIn", + "errorCode", + "consecutiveUseCount", + "rateLimitProtection", + ]; + for (const field of optionalFields) { + if (data[field] !== undefined && data[field] !== null) { + connection[field] = data[field]; + } + } + if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) { + connection.providerSpecificData = data.providerSpecificData; + } + + _insertConnectionRow(db, connection); + _reorderConnections(db, data.provider); + backupDbFile("pre-write"); + + return cleanNulls(connection); +} + +function _insertConnectionRow(db, conn) { + db.prepare( + ` + INSERT INTO provider_connections ( + id, provider, auth_type, name, email, priority, is_active, + access_token, refresh_token, expires_at, token_expires_at, + scope, project_id, test_status, error_code, last_error, + last_error_at, last_error_type, last_error_source, backoff_level, + rate_limited_until, health_check_interval, last_health_check_at, + last_tested, api_key, id_token, provider_specific_data, + expires_in, display_name, global_priority, default_model, + token_type, consecutive_use_count, rate_limit_protection, created_at, updated_at + ) VALUES ( + @id, @provider, @authType, @name, @email, @priority, @isActive, + @accessToken, @refreshToken, @expiresAt, @tokenExpiresAt, + @scope, @projectId, @testStatus, @errorCode, @lastError, + @lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel, + @rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt, + @lastTested, @apiKey, @idToken, @providerSpecificData, + @expiresIn, @displayName, @globalPriority, @defaultModel, + @tokenType, @consecutiveUseCount, @rateLimitProtection, @createdAt, @updatedAt + ) + ` + ).run({ + id: conn.id, + provider: conn.provider, + authType: conn.authType || null, + name: conn.name || null, + email: conn.email || null, + priority: conn.priority || 0, + isActive: conn.isActive === false ? 0 : 1, + accessToken: conn.accessToken || null, + refreshToken: conn.refreshToken || null, + expiresAt: conn.expiresAt || null, + tokenExpiresAt: conn.tokenExpiresAt || null, + scope: conn.scope || null, + projectId: conn.projectId || null, + testStatus: conn.testStatus || null, + errorCode: conn.errorCode || null, + lastError: conn.lastError || null, + lastErrorAt: conn.lastErrorAt || null, + lastErrorType: conn.lastErrorType || null, + lastErrorSource: conn.lastErrorSource || null, + backoffLevel: conn.backoffLevel || 0, + rateLimitedUntil: conn.rateLimitedUntil || null, + healthCheckInterval: conn.healthCheckInterval || null, + lastHealthCheckAt: conn.lastHealthCheckAt || null, + lastTested: conn.lastTested || null, + apiKey: conn.apiKey || null, + idToken: conn.idToken || null, + providerSpecificData: conn.providerSpecificData + ? JSON.stringify(conn.providerSpecificData) + : null, + expiresIn: conn.expiresIn || null, + displayName: conn.displayName || null, + globalPriority: conn.globalPriority || null, + defaultModel: conn.defaultModel || null, + tokenType: conn.tokenType || null, + consecutiveUseCount: conn.consecutiveUseCount || 0, + rateLimitProtection: + conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0, + createdAt: conn.createdAt, + updatedAt: conn.updatedAt, + }); +} + +function _updateConnectionRow(db, id, data) { + const now = data.updatedAt || new Date().toISOString(); + db.prepare( + ` + UPDATE provider_connections SET + provider = @provider, auth_type = @authType, name = @name, email = @email, + priority = @priority, is_active = @isActive, access_token = @accessToken, + refresh_token = @refreshToken, expires_at = @expiresAt, token_expires_at = @tokenExpiresAt, + scope = @scope, project_id = @projectId, test_status = @testStatus, error_code = @errorCode, + last_error = @lastError, last_error_at = @lastErrorAt, last_error_type = @lastErrorType, + last_error_source = @lastErrorSource, backoff_level = @backoffLevel, + rate_limited_until = @rateLimitedUntil, health_check_interval = @healthCheckInterval, + last_health_check_at = @lastHealthCheckAt, last_tested = @lastTested, api_key = @apiKey, + id_token = @idToken, provider_specific_data = @providerSpecificData, + expires_in = @expiresIn, display_name = @displayName, global_priority = @globalPriority, + default_model = @defaultModel, token_type = @tokenType, + consecutive_use_count = @consecutiveUseCount, + rate_limit_protection = @rateLimitProtection, + updated_at = @updatedAt + WHERE id = @id + ` + ).run({ + id, + provider: data.provider, + authType: data.authType || null, + name: data.name || null, + email: data.email || null, + priority: data.priority || 0, + isActive: data.isActive === false ? 0 : 1, + accessToken: data.accessToken || null, + refreshToken: data.refreshToken || null, + expiresAt: data.expiresAt || null, + tokenExpiresAt: data.tokenExpiresAt || null, + scope: data.scope || null, + projectId: data.projectId || null, + testStatus: data.testStatus || null, + errorCode: data.errorCode || null, + lastError: data.lastError || null, + lastErrorAt: data.lastErrorAt || null, + lastErrorType: data.lastErrorType || null, + lastErrorSource: data.lastErrorSource || null, + backoffLevel: data.backoffLevel || 0, + rateLimitedUntil: data.rateLimitedUntil || null, + healthCheckInterval: data.healthCheckInterval || null, + lastHealthCheckAt: data.lastHealthCheckAt || null, + lastTested: data.lastTested || null, + apiKey: data.apiKey || null, + idToken: data.idToken || null, + providerSpecificData: data.providerSpecificData + ? JSON.stringify(data.providerSpecificData) + : null, + expiresIn: data.expiresIn || null, + displayName: data.displayName || null, + globalPriority: data.globalPriority || null, + defaultModel: data.defaultModel || null, + tokenType: data.tokenType || null, + consecutiveUseCount: data.consecutiveUseCount || 0, + rateLimitProtection: + data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0, + updatedAt: now, + }); +} + +export async function updateProviderConnection(id, data) { + const db = getDbInstance(); + const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); + if (!existing) return null; + + const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() }; + _updateConnectionRow(db, id, merged); + backupDbFile("pre-write"); + + if (data.priority !== undefined) { + _reorderConnections(db, existing.provider); + } + + return cleanNulls(merged); +} + +export async function deleteProviderConnection(id) { + const db = getDbInstance(); + const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); + if (!existing) return false; + + db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); + _reorderConnections(db, existing.provider); + backupDbFile("pre-write"); + return true; +} + +export async function deleteProviderConnectionsByProvider(providerId) { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); + backupDbFile("pre-write"); + return result.changes; +} + +export async function reorderProviderConnections(providerId) { + const db = getDbInstance(); + _reorderConnections(db, providerId); +} + +function _reorderConnections(db, providerId) { + const rows = db + .prepare( + "SELECT id, priority, updated_at FROM provider_connections WHERE provider = ? ORDER BY priority ASC, updated_at DESC" + ) + .all(providerId); + + const update = db.prepare("UPDATE provider_connections SET priority = ? WHERE id = ?"); + rows.forEach((row, index) => { + update.run(index + 1, row.id); + }); +} + +export async function cleanupProviderConnections() { + return 0; +} + +// ──────────────── Provider Nodes ──────────────── + +export async function getProviderNodes(filter = {}) { + const db = getDbInstance(); + let sql = "SELECT * FROM provider_nodes"; + const params = {}; + + if (filter.type) { + sql += " WHERE type = @type"; + params.type = filter.type; + } + + return db.prepare(sql).all(params).map(rowToCamel); +} + +export async function getProviderNodeById(id) { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); + return row ? rowToCamel(row) : null; +} + +export async function createProviderNode(data) { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const node = { + id: data.id || uuidv4(), + type: data.type, + name: data.name, + prefix: data.prefix || null, + apiType: data.apiType || null, + baseUrl: data.baseUrl || null, + createdAt: now, + updatedAt: now, + }; + + db.prepare( + ` + INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt) + ` + ).run(node); + + backupDbFile("pre-write"); + return node; +} + +export async function updateProviderNode(id, data) { + const db = getDbInstance(); + const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); + if (!existing) return null; + + const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() }; + + db.prepare( + ` + UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, + api_type = @apiType, base_url = @baseUrl, updated_at = @updatedAt + WHERE id = @id + ` + ).run({ + id, + type: merged.type, + name: merged.name, + prefix: merged.prefix || null, + apiType: merged.apiType || null, + baseUrl: merged.baseUrl || null, + updatedAt: merged.updatedAt, + }); + + backupDbFile("pre-write"); + return merged; +} + +export async function deleteProviderNode(id) { + const db = getDbInstance(); + const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id); + if (!existing) return null; + + db.prepare("DELETE FROM provider_nodes WHERE id = ?").run(id); + backupDbFile("pre-write"); + return rowToCamel(existing); +} diff --git a/src/lib/db/settings.js b/src/lib/db/settings.js new file mode 100644 index 0000000000..53c48ee0b6 --- /dev/null +++ b/src/lib/db/settings.js @@ -0,0 +1,365 @@ +/** + * db/settings.js — Settings, pricing, and proxy config. + */ + +import { getDbInstance } from "./core.js"; +import { backupDbFile } from "./backup.js"; +import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.js"; + +// ──────────────── Settings ──────────────── + +export async function getSettings() { + const db = getDbInstance(); + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); + const settings = { cloudEnabled: false, stickyRoundRobinLimit: 3, requireLogin: true }; + for (const row of rows) { + settings[row.key] = JSON.parse(row.value); + } + return settings; +} + +export async function updateSettings(updates) { + const db = getDbInstance(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)" + ); + const tx = db.transaction(() => { + for (const [key, value] of Object.entries(updates)) { + insert.run(key, JSON.stringify(value)); + } + }); + tx(); + backupDbFile("pre-write"); + return getSettings(); +} + +export async function isCloudEnabled() { + const settings = await getSettings(); + return settings.cloudEnabled === true; +} + +// ──────────────── Pricing ──────────────── + +export async function getPricing() { + const db = getDbInstance(); + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + const userPricing = {}; + for (const row of rows) { + userPricing[row.key] = JSON.parse(row.value); + } + + const { getDefaultPricing } = await import("@/shared/constants/pricing.js"); + const defaultPricing = getDefaultPricing(); + + const mergedPricing = {}; + for (const [provider, models] of Object.entries(defaultPricing)) { + mergedPricing[provider] = { ...models }; + if (userPricing[provider]) { + for (const [model, pricing] of Object.entries(userPricing[provider])) { + mergedPricing[provider][model] = mergedPricing[provider][model] + ? { ...mergedPricing[provider][model], ...pricing } + : pricing; + } + } + } + + for (const [provider, models] of Object.entries(userPricing)) { + if (!mergedPricing[provider]) { + mergedPricing[provider] = { ...models }; + } else { + for (const [model, pricing] of Object.entries(models)) { + if (!mergedPricing[provider][model]) { + mergedPricing[provider][model] = pricing; + } + } + } + } + + return mergedPricing; +} + +export async function getPricingForModel(provider, model) { + const pricing = await getPricing(); + if (pricing[provider]?.[model]) return pricing[provider][model]; + + const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels.js"); + const alias = PROVIDER_ID_TO_ALIAS[provider]; + if (alias && pricing[alias]) return pricing[alias][model] || null; + + const np = provider?.replace(/-cn$/, ""); + if (np && np !== provider && pricing[np]) return pricing[np][model] || null; + + return null; +} + +export async function updatePricing(pricingData) { + const db = getDbInstance(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('pricing', ?, ?)" + ); + + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + const existing = {}; + for (const row of rows) existing[row.key] = JSON.parse(row.value); + + const tx = db.transaction(() => { + for (const [provider, models] of Object.entries(pricingData)) { + insert.run(provider, JSON.stringify({ ...(existing[provider] || {}), ...models })); + } + }); + tx(); + backupDbFile("pre-write"); + + const updated = {}; + const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + for (const row of allRows) updated[row.key] = JSON.parse(row.value); + return updated; +} + +export async function resetPricing(provider, model) { + const db = getDbInstance(); + + if (model) { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'pricing' AND key = ?") + .get(provider); + if (row) { + const models = JSON.parse(row.value); + delete models[model]; + if (Object.keys(models).length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); + } else { + db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'pricing' AND key = ?").run( + JSON.stringify(models), + provider + ); + } + } + } else { + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider); + } + + backupDbFile("pre-write"); + const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); + const result = {}; + for (const row of allRows) result[row.key] = JSON.parse(row.value); + return result; +} + +export async function resetAllPricing() { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = 'pricing'").run(); + backupDbFile("pre-write"); + return {}; +} + +// ──────────────── Proxy Config ──────────────── + +const DEFAULT_PROXY_CONFIG = { global: null, providers: {}, combos: {}, keys: {} }; +const ALIAS_TO_PROVIDER_ID = Object.entries(PROVIDER_ID_TO_ALIAS).reduce( + (acc, [providerId, alias]) => { + if (alias) acc[alias] = providerId; + acc[providerId] = providerId; + return acc; + }, + {} +); + +function resolveProviderAliasOrId(providerOrAlias) { + if (typeof providerOrAlias !== "string") return providerOrAlias; + return ALIAS_TO_PROVIDER_ID[providerOrAlias] || providerOrAlias; +} + +function getComboModelProvider(modelEntry) { + if (modelEntry && typeof modelEntry.provider === "string") { + return resolveProviderAliasOrId(modelEntry.provider); + } + + const modelValue = + typeof modelEntry === "string" + ? modelEntry + : typeof modelEntry?.model === "string" + ? modelEntry.model + : null; + + if (!modelValue) return null; + + const [providerOrAlias] = modelValue.split("/", 1); + if (!providerOrAlias) return null; + return resolveProviderAliasOrId(providerOrAlias); +} + +function migrateProxyEntry(value) { + if (!value) return null; + if (typeof value === "object" && value.type) return value; + if (typeof value !== "string") return null; + + try { + const url = new URL(value); + return { + type: url.protocol.replace(":", "").replace("//", "") || "http", + host: url.hostname, + port: url.port || (url.protocol === "socks5:" ? "1080" : "8080"), + username: url.username || "", + password: url.password || "", + }; + } catch { + const parts = value.split(":"); + return { + type: "http", + host: parts[0] || value, + port: parts[1] || "8080", + username: "", + password: "", + }; + } +} + +export async function getProxyConfig() { + const db = getDbInstance(); + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'proxyConfig'").all(); + + const raw = { ...DEFAULT_PROXY_CONFIG }; + for (const row of rows) raw[row.key] = JSON.parse(row.value); + + let migrated = false; + if (raw.global && typeof raw.global === "string") { + raw.global = migrateProxyEntry(raw.global); + migrated = true; + } + if (raw.providers) { + for (const [k, v] of Object.entries(raw.providers)) { + if (typeof v === "string") { + raw.providers[k] = migrateProxyEntry(v); + migrated = true; + } + } + } + + if (migrated) { + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)" + ); + if (raw.global !== undefined) insert.run("global", JSON.stringify(raw.global)); + if (raw.providers) insert.run("providers", JSON.stringify(raw.providers)); + } + + return raw; +} + +export async function getProxyForLevel(level, id) { + const config = await getProxyConfig(); + if (level === "global") return config.global || null; + const map = config[level + "s"] || config[level] || {}; + return (id ? map[id] : null) || null; +} + +export async function setProxyForLevel(level, id, proxy) { + const db = getDbInstance(); + const config = await getProxyConfig(); + + if (level === "global") { + config.global = proxy || null; + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', 'global', ?)" + ).run(JSON.stringify(config.global)); + } else { + const mapKey = level + "s"; + if (!config[mapKey]) config[mapKey] = {}; + if (proxy) { + config[mapKey][id] = proxy; + } else { + delete config[mapKey][id]; + } + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)" + ).run(mapKey, JSON.stringify(config[mapKey])); + } + + backupDbFile("pre-write"); + return config; +} + +export async function deleteProxyForLevel(level, id) { + return setProxyForLevel(level, id, null); +} + +export async function resolveProxyForConnection(connectionId) { + const config = await getProxyConfig(); + + if (connectionId && config.keys?.[connectionId]) { + return { proxy: config.keys[connectionId], level: "key", levelId: connectionId }; + } + + const db = getDbInstance(); + const connection = db + .prepare("SELECT provider FROM provider_connections WHERE id = ?") + .get(connectionId); + + if (connection) { + if (config.combos && Object.keys(config.combos).length > 0) { + const combos = db.prepare("SELECT id, data FROM combos").all(); + for (const comboRow of combos) { + if (config.combos[comboRow.id]) { + try { + const combo = JSON.parse(comboRow.data); + const usesProvider = (combo.models || []).some( + (entry) => getComboModelProvider(entry) === connection.provider + ); + if (usesProvider) { + return { proxy: config.combos[comboRow.id], level: "combo", levelId: comboRow.id }; + } + } catch { + // Ignore malformed combo records during proxy resolution. + } + } + } + } + + if (config.providers?.[connection.provider]) { + return { + proxy: config.providers[connection.provider], + level: "provider", + levelId: connection.provider, + }; + } + } + + if (config.global) { + return { proxy: config.global, level: "global", levelId: null }; + } + + return { proxy: null, level: "direct", levelId: null }; +} + +export async function setProxyConfig(config) { + if (config.level !== undefined) { + return setProxyForLevel(config.level, config.id || null, config.proxy); + } + + const db = getDbInstance(); + const current = await getProxyConfig(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)" + ); + + const tx = db.transaction(() => { + if (config.global !== undefined) { + current.global = config.global || null; + insert.run("global", JSON.stringify(current.global)); + } + for (const mapKey of ["providers", "combos", "keys"]) { + if (config[mapKey]) { + current[mapKey] = { ...(current[mapKey] || {}), ...config[mapKey] }; + for (const [k, v] of Object.entries(current[mapKey])) { + if (!v) delete current[mapKey][k]; + } + insert.run(mapKey, JSON.stringify(current[mapKey])); + } + } + }); + tx(); + + backupDbFile("pre-write"); + return current; +} diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js new file mode 100644 index 0000000000..9ac426290b --- /dev/null +++ b/src/lib/initCloudSync.js @@ -0,0 +1,22 @@ +import initializeCloudSync from "@/shared/services/initializeCloudSync"; +import "@/lib/tokenHealthCheck"; // Proactive token health-check scheduler + +// Initialize cloud sync when this module is imported +let initialized = false; + +export async function ensureCloudSyncInitialized() { + if (!initialized) { + try { + await initializeCloudSync(); + initialized = true; + } catch (error) { + console.error("[ServerInit] Error initializing cloud sync:", error); + } + } + return initialized; +} + +// Auto-initialize when module loads +ensureCloudSyncInitialized().catch(console.log); + +export default ensureCloudSyncInitialized; diff --git a/src/lib/localDb.js b/src/lib/localDb.js new file mode 100644 index 0000000000..5d747529e8 --- /dev/null +++ b/src/lib/localDb.js @@ -0,0 +1,91 @@ +/** + * localDb.js — Re-export layer for backward compatibility. + * + * All 27+ consumer files import from "@/lib/localDb". + * This thin layer re-exports everything from the domain-specific DB modules, + * so zero consumer changes are needed. + */ + +export { + // Provider Connections + getProviderConnections, + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, + deleteProviderConnection, + deleteProviderConnectionsByProvider, + reorderProviderConnections, + cleanupProviderConnections, + + // Provider Nodes + getProviderNodes, + getProviderNodeById, + createProviderNode, + updateProviderNode, + deleteProviderNode, +} from "./db/providers.js"; + +export { + // Model Aliases + getModelAliases, + setModelAlias, + deleteModelAlias, + + // MITM Alias + getMitmAlias, + setMitmAliasAll, + + // Custom Models + getCustomModels, + getAllCustomModels, + addCustomModel, + removeCustomModel, +} from "./db/models.js"; + +export { + // Combos + getCombos, + getComboById, + getComboByName, + createCombo, + updateCombo, + deleteCombo, +} from "./db/combos.js"; + +export { + // API Keys + getApiKeys, + createApiKey, + deleteApiKey, + validateApiKey, + getApiKeyMetadata, +} from "./db/apiKeys.js"; + +export { + // Settings + getSettings, + updateSettings, + isCloudEnabled, + + // Pricing + getPricing, + getPricingForModel, + updatePricing, + resetPricing, + resetAllPricing, + + // Proxy Config + getProxyConfig, + getProxyForLevel, + setProxyForLevel, + deleteProxyForLevel, + resolveProxyForConnection, + setProxyConfig, +} from "./db/settings.js"; + +export { + // Backup Management + backupDbFile, + listDbBackups, + restoreDbBackup, +} from "./db/backup.js"; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js new file mode 100644 index 0000000000..042386d3e5 --- /dev/null +++ b/src/lib/oauth/constants/oauth.js @@ -0,0 +1,223 @@ +/** + * OAuth Configuration Constants + * + * Credentials read from env vars with hardcoded fallbacks. + * The hardcoded values are the application's built-in credentials + * used when users log in via the UI for the first time. + * Override via env vars or provider-credentials.json for custom setups. + */ + +// Claude OAuth Configuration (Authorization Code Flow with PKCE) +export const CLAUDE_CONFIG = { + clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + authorizeUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://console.anthropic.com/v1/oauth/token", + scopes: ["org:create_api_key", "user:profile", "user:inference"], + codeChallengeMethod: "S256", +}; + +// Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) +export const CODEX_CONFIG = { + clientId: process.env.CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + scope: "openid profile email offline_access", + codeChallengeMethod: "S256", + // Additional OpenAI-specific params + extraParams: { + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "codex_cli_rs", + }, +}; + +// Gemini (Google) OAuth Configuration (Standard OAuth2) +export const GEMINI_CONFIG = { + clientId: + process.env.GEMINI_OAUTH_CLIENT_ID || + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || "", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], +}; + +// Qwen OAuth Configuration (Device Code Flow with PKCE) +export const QWEN_CONFIG = { + clientId: process.env.QWEN_OAUTH_CLIENT_ID || "f0304373b74a44d2b584a3fb70ca9e56", + deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", + tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", + scope: "openid profile email model.completion", + codeChallengeMethod: "S256", +}; + +// iFlow OAuth Configuration (Authorization Code) +export const IFLOW_CONFIG = { + clientId: process.env.IFLOW_OAUTH_CLIENT_ID || "10009311001", + clientSecret: process.env.IFLOW_OAUTH_CLIENT_SECRET || "", + authorizeUrl: "https://iflow.cn/oauth", + tokenUrl: "https://iflow.cn/oauth/token", + userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo", + extraParams: { + loginMethod: "phone", + type: "phone", + }, +}; + +// Kimi Coding OAuth Configuration (Device Code Flow) +export const KIMI_CODING_CONFIG = { + clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", + deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", + tokenUrl: "https://auth.kimi.com/api/oauth/token", +}; + +// KiloCode OAuth Configuration (Custom Device Auth Flow) +export const KILOCODE_CONFIG = { + apiBaseUrl: "https://api.kilo.ai", + initiateUrl: "https://api.kilo.ai/api/device-auth/codes", + pollUrlBase: "https://api.kilo.ai/api/device-auth/codes", +}; + +// Cline OAuth Configuration (Local Callback Flow via app.cline.bot) +export const CLINE_CONFIG = { + appBaseUrl: "https://app.cline.bot", + apiBaseUrl: "https://api.cline.bot", + authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize", + tokenExchangeUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", +}; + +// Antigravity OAuth Configuration (Standard OAuth2 with Google) +export const ANTIGRAVITY_CONFIG = { + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || "", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ], + // Antigravity specific + apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiVersion: "v1internal", + loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1", + loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", + loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`, +}; + +// OpenAI OAuth Configuration (Authorization Code Flow with PKCE) +export const OPENAI_CONFIG = { + clientId: process.env.CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + scope: "openid profile email offline_access", + codeChallengeMethod: "S256", + extraParams: { + id_token_add_organizations: "true", + originator: "openai_native", + }, +}; + +// GitHub Copilot OAuth Configuration (Device Code Flow) +export const GITHUB_CONFIG = { + clientId: process.env.GITHUB_OAUTH_CLIENT_ID || "Iv1.b507a08c87ecfe98", + deviceCodeUrl: "https://github.com/login/device/code", + tokenUrl: "https://github.com/login/oauth/access_token", + userInfoUrl: "https://api.github.com/user", + scopes: "read:user", + apiVersion: "2022-11-28", // Updated to supported version + copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token", + userAgent: "GitHubCopilotChat/0.26.7", + editorVersion: "vscode/1.85.0", + editorPluginVersion: "copilot-chat/0.26.7", +}; + +// Kiro OAuth Configuration +// Supports multiple auth methods: +// 1. AWS Builder ID (Device Code Flow) +// 2. AWS IAM Identity Center/IDC (Device Code Flow with custom startUrl/region) +// 3. Google/GitHub Social Login (Authorization Code Flow - manual callback) +// 4. Import Token (paste refresh token from Kiro IDE) +export const KIRO_CONFIG = { + // AWS SSO OIDC endpoints for Builder ID/IDC (Device Code Flow) + ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com", + registerClientUrl: "https://oidc.us-east-1.amazonaws.com/client/register", + deviceAuthUrl: "https://oidc.us-east-1.amazonaws.com/device_authorization", + tokenUrl: "https://oidc.us-east-1.amazonaws.com/token", + // AWS Builder ID default start URL + startUrl: "https://view.awsapps.com/start", + // Client registration params + clientName: "kiro-oauth-client", + clientType: "public", + scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"], + grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6", + // Social auth endpoints (Google/GitHub via AWS Cognito) + socialAuthEndpoint: "https://prod.us-east-1.auth.desktop.kiro.dev", + socialLoginUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/login", + socialTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/oauth/token", + socialRefreshUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", + // Auth methods + authMethods: ["builder-id", "idc", "google", "github", "import"], +}; + +// Cursor OAuth Configuration (Import Token from Cursor IDE) +// Cursor stores credentials in SQLite database: state.vscdb +// Keys: cursorAuth/accessToken, storage.serviceMachineId +export const CURSOR_CONFIG = { + // API endpoints + apiEndpoint: "https://api2.cursor.sh", + chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", + modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", + // Additional endpoints + api3Endpoint: "https://api3.cursor.sh", // Telemetry + agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode + agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode + // Client metadata + clientVersion: "0.48.6", + clientType: "ide", + // Token storage locations (for user reference) + tokenStoragePaths: { + linux: "~/.config/Cursor/User/globalStorage/state.vscdb", + macos: "/Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb", + windows: "%APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb", + }, + // Database keys + dbKeys: { + accessToken: "cursorAuth/accessToken", + machineId: "storage.serviceMachineId", + }, +}; + +// OAuth timeout (5 minutes) +export const OAUTH_TIMEOUT = 300000; + +// Provider list +export const PROVIDERS = { + CLAUDE: "claude", + CODEX: "codex", + GEMINI: "gemini-cli", + QWEN: "qwen", + IFLOW: "iflow", + ANTIGRAVITY: "antigravity", + OPENAI: "openai", + GITHUB: "github", + KIRO: "kiro", + CURSOR: "cursor", + KILOCODE: "kilocode", + CLINE: "cline", +}; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js new file mode 100644 index 0000000000..f5cf562da3 --- /dev/null +++ b/src/lib/oauth/providers.js @@ -0,0 +1,1050 @@ +/** + * OAuth Provider Configurations and Handlers + * Centralized DRY approach for all OAuth providers + */ + +import { generatePKCE, generateState } from "./utils/pkce"; +import { + CLAUDE_CONFIG, + CODEX_CONFIG, + GEMINI_CONFIG, + QWEN_CONFIG, + IFLOW_CONFIG, + KIMI_CODING_CONFIG, + ANTIGRAVITY_CONFIG, + GITHUB_CONFIG, + KIRO_CONFIG, + CURSOR_CONFIG, + KILOCODE_CONFIG, + CLINE_CONFIG, +} from "./constants/oauth"; + +// Provider configurations +const PROVIDERS = { + claude: { + config: CLAUDE_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + code: "true", + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: config.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), + }, + + codex: { + config: CODEX_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: 1455, + callbackPath: "/auth/callback", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + ...config.extraParams, + state: state, + }; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${queryString}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }), + }, + + "gemini-cli": { + config: GEMINI_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID + let projectId = ""; + try { + const projectRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), + }, + + antigravity: { + config: ANTIGRAVITY_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + const headers = { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, + }; + const metadata = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }; + + // Fetch user info + const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Load Code Assist to get project ID and tier + let projectId = ""; + let tierId = "legacy-tier"; + try { + const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + method: "POST", + headers, + body: JSON.stringify({ metadata }), + }); + if (loadRes.ok) { + const data = await loadRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + // Extract tier ID + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; + } + } + } + } + } catch (e) { + console.log("Failed to load code assist:", e); + } + + // Onboard user to enable Gemini Code Assist + if (projectId) { + try { + for (let i = 0; i < 10; i++) { + const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { + method: "POST", + headers, + body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }), + }); + if (onboardRes.ok) { + const result = await onboardRes.json(); + if (result.done === true) { + // Extract final project ID from response + if (result.response?.cloudaicompanionProject) { + const respProject = result.response.cloudaicompanionProject; + projectId = + typeof respProject === "string" + ? respProject.trim() + : respProject.id || projectId; + } + break; + } + } + // Wait 5 seconds before retry + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } catch (e) { + console.log("Failed to onboard user:", e); + } + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), + }, + + iflow: { + config: IFLOW_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + loginMethod: config.extraParams.loginMethod, + type: config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: config.clientId, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Create Basic Auth header + const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch( + `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + const result = userInfoRes.ok ? await userInfoRes.json() : {}; + const userInfo = result.success ? result.data : {}; + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), + }, + + qwen: { + config: QWEN_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config, codeChallenge) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { resourceUrl: tokens.resource_url }, + }), + }, + + "kimi-coding": { + config: KIMI_CODING_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + const data = await response.json(); + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`, + verification_uri_complete: + data.verification_uri_complete || + `https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`, + expires_in: data.expires_in, + interval: data.interval || 5, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + }), + }); + + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + }), + }, + + github: { + config: GITHUB_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + // Handle response properly - if not ok, try to get error as text first + let data; + try { + data = await response.json(); + } catch (e) { + // If response is not JSON, get as text + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + postExchange: async (tokens) => { + // Get Copilot token using GitHub access token + const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; + + // Get user info from GitHub + const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const userInfo = userRes.ok ? await userRes.json() : {}; + + return { copilotToken, userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { + copilotToken: extra?.copilotToken?.token, + copilotTokenExpiresAt: extra?.copilotToken?.expires_at, + githubUserId: extra?.userInfo?.id, + githubLogin: extra?.userInfo?.login, + githubName: extra?.userInfo?.name, + githubEmail: extra?.userInfo?.email, + }, + }), + }, + + kiro: { + config: KIRO_CONFIG, + flowType: "device_code", + // Kiro uses AWS SSO OIDC - requires client registration first + requestDeviceCode: async (config) => { + // Step 1: Register client with AWS SSO OIDC + const registerRes = await fetch(config.registerClientUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + issuerUrl: config.issuerUrl, + }), + }); + + if (!registerRes.ok) { + const error = await registerRes.text(); + throw new Error(`Client registration failed: ${error}`); + } + + const clientInfo = await registerRes.json(); + + // Step 2: Request device authorization + const deviceRes = await fetch(config.deviceAuthUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: clientInfo.clientId, + clientSecret: clientInfo.clientSecret, + startUrl: config.startUrl, + }), + }); + + if (!deviceRes.ok) { + const error = await deviceRes.text(); + throw new Error(`Device authorization failed: ${error}`); + } + + const deviceData = await deviceRes.json(); + + // Return combined data for polling + return { + device_code: deviceData.deviceCode, + user_code: deviceData.userCode, + verification_uri: deviceData.verificationUri, + verification_uri_complete: deviceData.verificationUriComplete, + expires_in: deviceData.expiresIn, + interval: deviceData.interval || 5, + // Store client credentials for token exchange + _clientId: clientInfo.clientId, + _clientSecret: clientInfo.clientSecret, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: extraData?._clientId, + clientSecret: extraData?._clientSecret, + deviceCode: deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // AWS SSO OIDC returns camelCase + if (data.accessToken) { + return { + ok: true, + data: { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_in: data.expiresIn, + // Store client credentials for refresh + _clientId: extraData?._clientId, + _clientSecret: extraData?._clientSecret, + }, + }; + } + + return { + ok: false, + data: { + error: data.error || "authorization_pending", + error_description: data.error_description || data.message, + }, + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { + clientId: tokens._clientId, + clientSecret: tokens._clientSecret, + }, + }), + }, + + cursor: { + config: CURSOR_CONFIG, + flowType: "import_token", + // Cursor uses import token flow - tokens are extracted from local SQLite database + // No OAuth flow needed, handled by /api/oauth/cursor/import route + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + machineId: tokens.machineId, + authMethod: "imported", + }, + }), + }, + + kilocode: { + config: KILOCODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + // KiloCode uses a custom device auth flow (not standard OAuth) + const response = await fetch(config.initiateUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later."); + } + const error = await response.text(); + throw new Error(`Device auth initiation failed: ${error}`); + } + + const data = await response.json(); + // Map KiloCode response to standard device code format + return { + device_code: data.code, // Use code as device_code for polling + user_code: data.code, + verification_uri: data.verificationUrl, + verification_uri_complete: data.verificationUrl, + expires_in: data.expiresIn || 300, + interval: 3, + }; + }, + pollToken: async (config, deviceCode) => { + // KiloCode polls by GET /api/device-auth/codes/{code} + const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); + + // Handle custom status codes + if (response.status === 202) { + // Still pending + return { ok: false, data: { error: "authorization_pending" } }; + } + if (response.status === 403) { + return { + ok: false, + data: { error: "access_denied", error_description: "Authorization denied by user" }, + }; + } + if (response.status === 410) { + return { + ok: false, + data: { error: "expired_token", error_description: "Authorization code expired" }, + }; + } + + if (!response.ok) { + return { + ok: false, + data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` }, + }; + } + + // Success - map KiloCode {token, userEmail} to standard format + const data = await response.json(); + if (data.status === "approved" && data.token) { + return { + ok: true, + data: { + access_token: data.token, + _userEmail: data.userEmail, + }, + }; + } + + return { ok: false, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, // KiloCode JWT doesn't have refresh tokens + expiresIn: null, // JWT expiry is embedded in the token + email: tokens._userEmail, + }), + }, + + cline: { + config: CLINE_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Cline callback returns base64-encoded JSON with tokens directly in the code param + // The code may have a signature suffix after the JSON - strip it + try { + // Add padding if needed + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) { + base64 += "=".repeat(padding); + } + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + // Find the JSON boundary (ends with }) + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) { + throw new Error("No JSON found in decoded code"); + } + const jsonStr = decoded.substring(0, lastBrace + 1); + const tokenData = JSON.parse(jsonStr); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + // Fallback: try token exchange via API + const response = await fetch(config.tokenExchangeUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code: code, + client_type: "extension", + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Cline token exchange failed: ${error}`); + } + + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { + firstName: tokens.firstName, + lastName: tokens.lastName, + }, + }), + }, +}; + +/** + * Get provider handler + */ +export function getProvider(name) { + const provider = PROVIDERS[name]; + if (!provider) { + throw new Error(`Unknown provider: ${name}`); + } + return provider; +} + +/** + * Get all provider names + */ +export function getProviderNames() { + return Object.keys(PROVIDERS); +} + +/** + * Generate auth data for a provider + */ +export function generateAuthData(providerName, redirectUri) { + const provider = getProvider(providerName); + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + let authUrl; + if (provider.flowType === "device_code") { + // Device code flow doesn't have auth URL upfront + authUrl = null; + } else if (provider.flowType === "authorization_code_pkce") { + authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge); + } else { + authUrl = provider.buildAuthUrl(provider.config, redirectUri, state); + } + + return { + authUrl, + state, + codeVerifier, + codeChallenge, + redirectUri, + flowType: provider.flowType, + fixedPort: provider.fixedPort, + callbackPath: provider.callbackPath || "/callback", + }; +} + +/** + * Exchange code for tokens + */ +export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) { + const provider = getProvider(providerName); + + const tokens = await provider.exchangeToken( + provider.config, + code, + redirectUri, + codeVerifier, + state + ); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + +/** + * Request device code (for device_code flow) + */ +export async function requestDeviceCode(providerName, codeChallenge) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + return await provider.requestDeviceCode(provider.config, codeChallenge); +} + +/** + * Poll for token (for device_code flow) + * @param {string} providerName - Provider name + * @param {string} deviceCode - Device code from requestDeviceCode + * @param {string} codeVerifier - PKCE code verifier (optional for some providers) + * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) + */ +export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); + + if (result.ok) { + // For device code flows, success is only when we have an access token + if (result.data.access_token) { + // Call postExchange to get additional data (copilotToken, userInfo, etc.) + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(result.data); + } + return { success: true, tokens: provider.mapTokens(result.data, extra) }; + } else { + // Check if it's still pending authorization + if (result.data.error === "authorization_pending" || result.data.error === "slow_down") { + // This is not a failure, just still waiting + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description || result.data.message, + pending: result.data.error === "authorization_pending", + }; + } else { + // Actual error + return { + success: false, + error: result.data.error || "no_access_token", + errorDescription: + result.data.error_description || result.data.message || "No access token received", + }; + } + } + } + + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description, + }; +} diff --git a/src/lib/oauth/services/antigravity.js b/src/lib/oauth/services/antigravity.js new file mode 100644 index 0000000000..8d41046088 --- /dev/null +++ b/src/lib/oauth/services/antigravity.js @@ -0,0 +1,331 @@ +import crypto from "crypto"; +import open from "open"; +import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Antigravity OAuth Service + * Uses standard OAuth2 Authorization Code flow (similar to Gemini) + */ +export class AntigravityService { + constructor() { + this.config = ANTIGRAVITY_CONFIG; + } + + /** + * Build Antigravity authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: this.config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info from Google + */ + async getUserInfo(accessToken) { + const response = await fetch(`${this.config.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Get common headers for Antigravity API calls + */ + getApiHeaders(accessToken) { + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": this.config.loadCodeAssistUserAgent, + "X-Goog-Api-Client": this.config.loadCodeAssistApiClient, + "Client-Metadata": this.config.loadCodeAssistClientMetadata, + }; + } + + /** + * Get metadata object for API calls + */ + getMetadata() { + return { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }; + } + + /** + * Fetch Project ID and Tier from loadCodeAssist API + */ + async loadCodeAssist(accessToken) { + const response = await fetch(this.config.loadCodeAssistEndpoint, { + method: "POST", + headers: this.getApiHeaders(accessToken), + body: JSON.stringify({ metadata: this.getMetadata() }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to load code assist: ${errorText}`); + } + + const data = await response.json(); + + // Extract project ID + let projectId = data.cloudaicompanionProject; + if (typeof projectId === "object" && projectId !== null && projectId.id) { + projectId = projectId.id; + } + + // Extract tier ID (default to legacy-tier) + let tierId = "legacy-tier"; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; + } + } + } + + return { projectId, tierId, raw: data }; + } + + /** + * Onboard user to enable Gemini Code Assist for the project + */ + async onboardUser(accessToken, projectId, tierId) { + const response = await fetch(this.config.onboardUserEndpoint, { + method: "POST", + headers: this.getApiHeaders(accessToken), + body: JSON.stringify({ + tierId, + metadata: this.getMetadata(), + cloudaicompanionProject: projectId, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to onboard user: ${errorText}`); + } + + return await response.json(); + } + + /** + * Complete onboarding flow with retry + */ + async completeOnboarding(accessToken, projectId, tierId, maxRetries = 10) { + for (let i = 0; i < maxRetries; i++) { + const result = await this.onboardUser(accessToken, projectId, tierId); + + if (result.done === true) { + // Extract final project ID from response + let finalProjectId = projectId; + if (result.response?.cloudaicompanionProject) { + const respProject = result.response.cloudaicompanionProject; + if (typeof respProject === "string") { + finalProjectId = respProject.trim(); + } else if (respProject.id) { + finalProjectId = respProject.id.trim(); + } + } + return { success: true, projectId: finalProjectId }; + } + + // Wait 5 seconds before retry + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + throw new Error("Onboarding timeout - please try again"); + } + + /** + * Fetch Project ID from loadCodeAssist API (legacy method for compatibility) + */ + async fetchProjectId(accessToken) { + const { projectId } = await this.loadCodeAssist(accessToken); + if (!projectId) { + throw new Error("No cloudaicompanionProject found in response"); + } + return projectId; + } + + /** + * Save Antigravity tokens to server + */ + async saveTokens(tokens, userInfo, projectId) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/antigravity`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: userInfo.email, + projectId: projectId, // Send projectId to server + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Antigravity OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Antigravity OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for Antigravity authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for Antigravity authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Loading Code Assist configuration..."; + + // Load Code Assist to get project ID and tier + const { projectId, tierId } = await this.loadCodeAssist(tokens.access_token); + + if (!projectId) { + throw new Error( + "No Google Cloud Project found. Please ensure you have a GCP project with Gemini Code Assist enabled." + ); + } + + spinner.text = "Onboarding to Gemini Code Assist..."; + + // Complete onboarding to enable Gemini Code Assist + const onboardResult = await this.completeOnboarding(tokens.access_token, projectId, tierId); + const finalProjectId = onboardResult.projectId || projectId; + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo, finalProjectId); + + spinner.succeed( + `Antigravity connected successfully! (${userInfo.email}, Project: ${finalProjectId})` + ); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/claude.js b/src/lib/oauth/services/claude.js new file mode 100644 index 0000000000..1ed217ad78 --- /dev/null +++ b/src/lib/oauth/services/claude.js @@ -0,0 +1,135 @@ +import { OAuthService } from "./oauth.js"; +import { CLAUDE_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Claude OAuth Service + */ +export class ClaudeService extends OAuthService { + constructor() { + super(CLAUDE_CONFIG); + } + + /** + * Build Claude authorization URL + */ + buildClaudeAuthUrl(redirectUri, state, codeChallenge) { + const scopeStr = CLAUDE_CONFIG.scopes.join(" "); + const params = new URLSearchParams({ + code: "true", + client_id: CLAUDE_CONFIG.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: scopeStr, + code_challenge: codeChallenge, + code_challenge_method: CLAUDE_CONFIG.codeChallengeMethod, + state: state, + }); + + return `${CLAUDE_CONFIG.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange Claude authorization code (with special handling) + */ + async exchangeClaudeCode(code, redirectUri, codeVerifier, state) { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + // Claude uses JSON format (not form-urlencoded) + const tokenPayload = { + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: CLAUDE_CONFIG.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }; + + const response = await fetch(CLAUDE_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(tokenPayload), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Save Claude tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + // Server will auto-generate displayName based on existing account count + const response = await fetch(`${server}/api/cli/providers/claude`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Claude OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Claude OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Authenticate and get authorization code + const { code, state, codeVerifier, redirectUri } = await this.authenticate( + "Claude", + this.buildClaudeAuthUrl.bind(this) + ); + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeClaudeCode(code, redirectUri, codeVerifier, state); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Claude connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js new file mode 100644 index 0000000000..7e4ee293a8 --- /dev/null +++ b/src/lib/oauth/services/codex.js @@ -0,0 +1,149 @@ +import open from "open"; +import { OAuthService } from "./oauth.js"; +import { CODEX_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Codex (OpenAI) OAuth Service + */ +export class CodexService extends OAuthService { + constructor() { + super(CODEX_CONFIG); + } + + /** + * Build Codex authorization URL + */ + buildCodexAuthUrl(redirectUri, state, codeChallenge) { + // Build URL manually to ensure space encoding as %20 instead of + + const params = { + response_type: "code", + client_id: CODEX_CONFIG.clientId, + redirect_uri: redirectUri, + scope: CODEX_CONFIG.scope, + code_challenge: codeChallenge, + code_challenge_method: CODEX_CONFIG.codeChallengeMethod, + ...CODEX_CONFIG.extraParams, + state: state, + }; + + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + + return `${CODEX_CONFIG.authorizeUrl}?${queryString}`; + } + + /** + * Save Codex tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/codex`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Codex OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Codex OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback (use fixed port 1455 like real Codex CLI) + const fixedPort = 1455; + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }, fixedPort); + + const redirectUri = `http://localhost:${port}/auth/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate PKCE + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + // Build authorization URL + const authUrl = this.buildCodexAuthUrl(redirectUri, state, codeChallenge); + + console.log("\nOpening browser for OpenAI authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for OpenAI authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens (Codex uses form-urlencoded) + const tokens = await this.exchangeCode( + callbackParams.code, + redirectUri, + codeVerifier, + "application/x-www-form-urlencoded" + ); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Codex connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/cursor.js b/src/lib/oauth/services/cursor.js new file mode 100644 index 0000000000..167eacdeae --- /dev/null +++ b/src/lib/oauth/services/cursor.js @@ -0,0 +1,179 @@ +import { CURSOR_CONFIG } from "../constants/oauth.js"; + +/** + * Cursor IDE OAuth Service + * Supports Import Token method from Cursor IDE's local SQLite database + * + * Token Location: + * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb + * - macOS: /Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb + * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + * + * Database Keys: + * - cursorAuth/accessToken: The access token + * - storage.serviceMachineId: Machine ID for checksum + */ + +export class CursorService { + constructor() { + this.config = CURSOR_CONFIG; + } + + /** + * Generate Cursor checksum (jyh cipher) + * Algorithm: XOR timestamp bytes with rolling key (initial 165), then base64 encode + * Format: {encoded_timestamp},{machineId} + */ + generateChecksum(machineId) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + let key = 165; + const encoded = []; + + for (let i = 0; i < timestamp.length; i++) { + const charCode = timestamp.charCodeAt(i); + encoded.push(charCode ^ key); + key = (key + charCode) & 0xff; // Rolling key update + } + + const base64Encoded = Buffer.from(encoded).toString("base64"); + return `${base64Encoded},${machineId}`; + } + + /** + * Build request headers for Cursor API + */ + buildHeaders(accessToken, machineId, ghostMode = false) { + const checksum = this.generateChecksum(machineId); + + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/connect+proto", + "Connect-Protocol-Version": "1", + "x-cursor-client-version": this.config.clientVersion, + "x-cursor-client-type": this.config.clientType, + "x-cursor-client-os": this.detectOS(), + "x-cursor-client-arch": this.detectArch(), + "x-cursor-client-device-type": "desktop", + "x-cursor-checksum": checksum, + "x-ghost-mode": ghostMode ? "true" : "false", + }; + } + + /** + * Detect OS for headers + */ + detectOS() { + if (typeof process !== "undefined") { + const platform = process.platform; + if (platform === "win32") return "windows"; + if (platform === "darwin") return "macos"; + return "linux"; + } + return "linux"; + } + + /** + * Detect architecture for headers + */ + detectArch() { + if (typeof process !== "undefined") { + const arch = process.arch; + if (arch === "x64") return "x86_64"; + if (arch === "arm64") return "aarch64"; + return arch; + } + return "x86_64"; + } + + /** + * Validate and import token from Cursor IDE + * Note: We skip API validation because Cursor API uses complex protobuf format. + * Token will be validated when actually used for requests. + * @param {string} accessToken - Access token from state.vscdb + * @param {string} machineId - Machine ID from state.vscdb + */ + async validateImportToken(accessToken, machineId) { + // Basic validation + if (!accessToken || typeof accessToken !== "string") { + throw new Error("Access token is required"); + } + + if (!machineId || typeof machineId !== "string") { + throw new Error("Machine ID is required"); + } + + // Token format validation (Cursor tokens are typically long strings) + if (accessToken.length < 50) { + throw new Error("Invalid token format. Token appears too short."); + } + + // Machine ID format validation (should be UUID-like) + const uuidRegex = /^[a-f0-9-]{32,}$/i; + if (!uuidRegex.test(machineId.replace(/-/g, ""))) { + throw new Error("Invalid machine ID format. Expected UUID format."); + } + + // Note: We don't validate against API because Cursor uses complex protobuf. + // Token will be validated when used for actual requests. + + return { + accessToken, + machineId, + expiresIn: 86400, // Cursor tokens typically last 24 hours + authMethod: "imported", + }; + } + + /** + * Extract user info from token if possible + * Cursor tokens may contain encoded user info + */ + extractUserInfo(accessToken) { + try { + // Try to decode as JWT + const parts = accessToken.split("."); + if (parts.length === 3) { + let payload = parts[1]; + while (payload.length % 4) { + payload += "="; + } + const decoded = JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString() + ); + return { + email: decoded.email || decoded.sub, + userId: decoded.sub || decoded.user_id, + }; + } + } catch { + // Token is not a JWT, that's okay + } + + return null; + } + + /** + * Get token storage path instructions for user + */ + getTokenStorageInstructions() { + return { + title: "How to get your Cursor token", + steps: [ + "1. Open Cursor IDE and make sure you're logged in", + "2. Find the state.vscdb file:", + ` - Linux: ${this.config.tokenStoragePaths.linux}`, + ` - macOS: ${this.config.tokenStoragePaths.macos}`, + ` - Windows: ${this.config.tokenStoragePaths.windows}`, + "3. Open the database with SQLite browser or CLI:", + " sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='cursorAuth/accessToken'\"", + "4. Also get the machine ID:", + " sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='storage.serviceMachineId'\"", + "5. Paste both values in the form below", + ], + alternativeMethod: [ + "Or use this one-liner to get both values:", + "sqlite3 state.vscdb \"SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')\"", + ], + }; + } +} diff --git a/src/lib/oauth/services/gemini.js b/src/lib/oauth/services/gemini.js new file mode 100644 index 0000000000..40a59a84e7 --- /dev/null +++ b/src/lib/oauth/services/gemini.js @@ -0,0 +1,245 @@ +import crypto from "crypto"; +import open from "open"; +import { GEMINI_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Gemini CLI (Google Cloud Code Assist) OAuth Service + * Uses standard OAuth2 Authorization Code flow (no PKCE) + */ +export class GeminiCLIService { + constructor() { + this.config = GEMINI_CONFIG; + } + + /** + * Build Gemini CLI authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: this.config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Fetch project ID from Google Cloud Code Assist + */ + async fetchProjectId(accessToken) { + const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + 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) { + const error = await response.text(); + throw new Error(`Failed to fetch project ID: ${error}`); + } + + const data = await response.json(); + + // Extract project ID + let projectId = ""; + if (typeof data.cloudaicompanionProject === "string") { + projectId = data.cloudaicompanionProject.trim(); + } else if (data.cloudaicompanionProject?.id) { + projectId = data.cloudaicompanionProject.id.trim(); + } + + if (!projectId) { + throw new Error("No project ID found in response"); + } + + return projectId; + } + + /** + * Get user info from Google + */ + async getUserInfo(accessToken) { + const response = await fetch(`${this.config.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Save Gemini CLI tokens to server + */ + async saveTokens(tokens, userInfo, projectId) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/gemini-cli`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: userInfo.email, + projectId: projectId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Gemini OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Gemini OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for Google authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for Google authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Fetching project ID..."; + + // Fetch project ID + const projectId = await this.fetchProjectId(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo, projectId); + + spinner.succeed( + `Gemini CLI connected successfully! (${userInfo.email}, Project: ${projectId})` + ); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/github.js b/src/lib/oauth/services/github.js new file mode 100644 index 0000000000..aef54ec3be --- /dev/null +++ b/src/lib/oauth/services/github.js @@ -0,0 +1,227 @@ +import { OAuthService } from "./oauth.js"; +import { GITHUB_CONFIG } from "../constants/oauth.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * GitHub Copilot OAuth Service + * Uses Device Code Flow for authentication + */ +export class GitHubService extends OAuthService { + constructor() { + super(GITHUB_CONFIG); + } + + /** + * Get device code for GitHub authentication + */ + async getDeviceCode() { + const response = await fetch(`${GITHUB_CONFIG.deviceCodeUrl}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: GITHUB_CONFIG.clientId, + scope: GITHUB_CONFIG.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get device code: ${error}`); + } + + return await response.json(); + } + + /** + * Poll for access token using device code + */ + async pollAccessToken(deviceCode, verificationUri, userCode, interval = 5000) { + const spinner = createSpinner("Waiting for GitHub authentication...").start(); + + // Show user code and verification URL + console.log(`\nPlease visit: ${verificationUri}`); + console.log(`Enter code: ${userCode}\n`); + + // Open browser automatically + try { + const open = (await import("open")).default; + await open(verificationUri); + } catch (error) { + console.log("Could not open browser automatically. Please visit the URL above manually."); + } + + // Poll for access token + while (true) { + await new Promise((resolve) => setTimeout(resolve, interval)); + + const response = await fetch(`${GITHUB_CONFIG.tokenUrl}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: GITHUB_CONFIG.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await response.json(); + + if (data.access_token) { + spinner.succeed("GitHub authentication successful!"); + return { + access_token: data.access_token, + token_type: data.token_type, + scope: data.scope, + }; + } else if (data.error === "authorization_pending") { + // Continue polling + continue; + } else if (data.error === "slow_down") { + // Increase polling interval + interval += 5000; + continue; + } else if (data.error === "expired_token") { + spinner.fail("Device code expired. Please try again."); + throw new Error("Device code expired"); + } else if (data.error === "access_denied") { + spinner.fail("Access denied by user."); + throw new Error("Access denied"); + } else { + spinner.fail("Failed to get access token."); + throw new Error(data.error_description || data.error); + } + } + } + + /** + * Get Copilot token using GitHub access token + */ + async getCopilotToken(accessToken) { + const response = await fetch(`${GITHUB_CONFIG.copilotTokenUrl}`, { + headers: { + Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get Copilot token: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info using GitHub access token + */ + async getUserInfo(accessToken) { + const response = await fetch(`${GITHUB_CONFIG.userInfoUrl}`, { + headers: { + Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Complete GitHub Copilot authentication flow + */ + async authenticate() { + try { + // Get device code + const deviceResponse = await this.getDeviceCode(); + + // Poll for access token + const tokenResponse = await this.pollAccessToken( + deviceResponse.device_code, + deviceResponse.verification_uri, + deviceResponse.user_code + ); + + // Get Copilot token + const copilotToken = await this.getCopilotToken(tokenResponse.access_token); + + // Get user info + const userInfo = await this.getUserInfo(tokenResponse.access_token); + + console.log(`\n✅ Successfully authenticated as ${userInfo.login}`); + + return { + accessToken: tokenResponse.access_token, + copilotToken: copilotToken.token, + refreshToken: null, // GitHub device flow doesn't return refresh token + expiresIn: copilotToken.expires_at, + userInfo: { + id: userInfo.id, + login: userInfo.login, + name: userInfo.name, + email: userInfo.email, + }, + copilotTokenInfo: copilotToken, + }; + } catch (error) { + throw new Error(`GitHub authentication failed: ${error.message}`); + } + } + + /** + * Connect to server with GitHub credentials + */ + async connect() { + try { + // Authenticate with GitHub + const authResult = await this.authenticate(); + + // Send credentials to server + const { server, token, userId } = await import("../config/index.js").then((m) => + m.getServerCredentials() + ); + const spinner = (await import("../utils/ui.js")).spinner("Connecting to server...").start(); + + const response = await fetch(`${server}/api/cli/providers/github`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: authResult.accessToken, + copilotToken: authResult.copilotToken, + userInfo: authResult.userInfo, + copilotTokenInfo: authResult.copilotTokenInfo, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to connect to server"); + } + + spinner.succeed("GitHub Copilot connected successfully!"); + console.log(`\nConnected as: ${authResult.userInfo.login}`); + } catch (error) { + const { error: showError } = await import("../utils/ui.js"); + showError(`GitHub connection failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/iflow.js b/src/lib/oauth/services/iflow.js new file mode 100644 index 0000000000..b2ad152843 --- /dev/null +++ b/src/lib/oauth/services/iflow.js @@ -0,0 +1,201 @@ +import crypto from "crypto"; +import open from "open"; +import { IFLOW_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * iFlow OAuth Service + * Uses Authorization Code flow with Basic Auth + */ +export class IFlowService { + constructor() { + this.config = IFLOW_CONFIG; + } + + /** + * Build iFlow authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + loginMethod: this.config.extraParams.loginMethod, + type: this.config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: this.config.clientId, + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + // Create Basic Auth header + const basicAuth = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString( + "base64" + ); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info from iFlow + */ + async getUserInfo(accessToken) { + const response = await fetch( + `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error("Failed to get user info"); + } + + return result.data; + } + + /** + * Save iFlow tokens to server + */ + async saveTokens(tokens, userInfo) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/iflow`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: userInfo.apiKey, + email: userInfo.email || userInfo.phone, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete iFlow OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting iFlow OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for iFlow authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for iFlow authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info (includes API key) + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo); + + spinner.succeed(`iFlow connected successfully! (${userInfo.email || userInfo.phone})`); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js new file mode 100644 index 0000000000..af3c0458b2 --- /dev/null +++ b/src/lib/oauth/services/index.js @@ -0,0 +1,15 @@ +/** + * Export all services + */ + +export { OAuthService } from "./oauth.js"; +export { ClaudeService } from "./claude.js"; +export { CodexService } from "./codex.js"; +export { GeminiCLIService } from "./gemini.js"; +export { QwenService } from "./qwen.js"; +export { IFlowService } from "./iflow.js"; +export { AntigravityService } from "./antigravity.js"; +export { OpenAIService } from "./openai.js"; +export { GitHubService } from "./github.js"; +export { KiroService } from "./kiro.js"; +export { CursorService } from "./cursor.js"; diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js new file mode 100644 index 0000000000..40e39a0817 --- /dev/null +++ b/src/lib/oauth/services/kiro.js @@ -0,0 +1,276 @@ +import { KIRO_CONFIG } from "../constants/oauth.js"; + +/** + * Kiro OAuth Service + * Supports multiple authentication methods: + * 1. AWS Builder ID (Device Code Flow) + * 2. AWS IAM Identity Center/IDC (Device Code Flow) + * 3. Google/GitHub Social Login (Authorization Code Flow + Manual Callback) + * 4. Import Token (Manual refresh token paste) + */ + +const KIRO_AUTH_SERVICE = "https://prod.us-east-1.auth.desktop.kiro.dev"; + +export class KiroService { + /** + * Register OIDC client with AWS SSO + * Returns clientId and clientSecret for device code flow + */ + async registerClient(region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/client/register`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientName: KIRO_CONFIG.clientName, + clientType: KIRO_CONFIG.clientType, + scopes: KIRO_CONFIG.scopes, + grantTypes: KIRO_CONFIG.grantTypes, + issuerUrl: KIRO_CONFIG.issuerUrl, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to register client: ${error}`); + } + + const data = await response.json(); + return { + clientId: data.clientId, + clientSecret: data.clientSecret, + clientSecretExpiresAt: data.clientSecretExpiresAt, + }; + } + + /** + * Start device authorization for AWS Builder ID or IDC + */ + async startDeviceAuthorization(clientId, clientSecret, startUrl, region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + startUrl, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to start device authorization: ${error}`); + } + + const data = await response.json(); + return { + deviceCode: data.deviceCode, + userCode: data.userCode, + verificationUri: data.verificationUri, + verificationUriComplete: data.verificationUriComplete, + expiresIn: data.expiresIn, + interval: data.interval || 5, + }; + } + + /** + * Poll for token using device code (AWS Builder ID/IDC) + */ + async pollDeviceToken(clientId, clientSecret, deviceCode, region = "us-east-1") { + const endpoint = `https://oidc.${region}.amazonaws.com/token`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await response.json(); + + // Handle pending/slow_down/errors + if (!response.ok || data.error) { + return { + success: false, + error: data.error, + errorDescription: data.error_description, + pending: data.error === "authorization_pending" || data.error === "slow_down", + }; + } + + return { + success: true, + tokens: { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: data.expiresIn, + tokenType: data.tokenType, + }, + }; + } + + /** + * Build Google/GitHub social login URL + * Returns authorization URL for manual callback flow + * Uses kiro:// custom protocol as required by AWS Cognito whitelist + */ + buildSocialLoginUrl(provider, codeChallenge, state) { + const idp = provider === "google" ? "Google" : "Github"; + // AWS Cognito only whitelists kiro:// protocol, not localhost + const redirectUri = "kiro://kiro.kiroAgent/authenticate-success"; + return `${KIRO_AUTH_SERVICE}/login?idp=${idp}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}&prompt=select_account`; + } + + /** + * Exchange authorization code for tokens (Social Login) + * Must use same redirect_uri as authorization request + */ + async exchangeSocialCode(code, codeVerifier) { + // Must match the redirect_uri used in buildSocialLoginUrl + const redirectUri = "kiro://kiro.kiroAgent/authenticate-success"; + + const response = await fetch(`${KIRO_AUTH_SERVICE}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + profileArn: data.profileArn, + expiresIn: data.expiresIn || 3600, + }; + } + + /** + * Refresh token using refresh token + */ + async refreshToken(refreshToken, providerSpecificData = {}) { + const { authMethod, clientId, clientSecret, region } = providerSpecificData; + + // AWS SSO OIDC refresh (Builder ID or IDC) + if (clientId && clientSecret) { + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientId, + clientSecret, + refreshToken, + grantType: "refresh_token", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken || refreshToken, + expiresIn: data.expiresIn, + }; + } + + // Social auth refresh (Google/GitHub) + const response = await fetch(`${KIRO_AUTH_SERVICE}/refreshToken`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refreshToken, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken || refreshToken, + profileArn: data.profileArn, + expiresIn: data.expiresIn || 3600, + }; + } + + /** + * Validate and import refresh token + */ + async validateImportToken(refreshToken) { + // Validate token format + if (!refreshToken.startsWith("aorAAAAAG")) { + throw new Error("Invalid token format. Token should start with aorAAAAAG..."); + } + + // Try to refresh to validate + try { + const result = await this.refreshToken(refreshToken); + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken || refreshToken, + profileArn: result.profileArn, + expiresIn: result.expiresIn, + authMethod: "imported", + }; + } catch (error) { + throw new Error(`Token validation failed: ${error.message}`); + } + } + + /** + * Fetch user email from access token (optional, for display) + */ + extractEmailFromJWT(accessToken) { + try { + const parts = accessToken.split("."); + if (parts.length !== 3) return null; + + // Decode payload (add padding if needed) + let payload = parts[1]; + while (payload.length % 4) { + payload += "="; + } + + const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); + return decoded.email || decoded.preferred_username || decoded.sub; + } catch { + return null; + } + } +} diff --git a/src/lib/oauth/services/oauth.js b/src/lib/oauth/services/oauth.js new file mode 100644 index 0000000000..5a5d079a39 --- /dev/null +++ b/src/lib/oauth/services/oauth.js @@ -0,0 +1,161 @@ +import open from "open"; +import { startLocalServer } from "../utils/server.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; +import { OAUTH_TIMEOUT } from "../constants/oauth.js"; + +/** + * Generic OAuth Authorization Code Flow with PKCE + */ +export class OAuthService { + constructor(config) { + this.config = config; + } + + /** + * Build authorization URL + */ + buildAuthUrl(redirectUri, state, codeChallenge, extraParams = {}) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + state: state, + code_challenge: codeChallenge, + code_challenge_method: this.config.codeChallengeMethod, + ...extraParams, + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Start local server and wait for callback + */ + async startAuthFlow(authUrl, providerName) { + const spinner = createSpinner("Starting local server...").start(); + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + return { + redirectUri, + port, + close, + waitForCallback: async () => { + spinner.start(`Waiting for ${providerName} authorization...`); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, OAUTH_TIMEOUT); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + spinner.stop(); + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + return callbackParams; + }, + }; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode( + code, + redirectUri, + codeVerifier, + contentType = "application/x-www-form-urlencoded" + ) { + const body = + contentType === "application/json" + ? JSON.stringify({ + grant_type: "authorization_code", + client_id: this.config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }) + : new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": contentType, + Accept: "application/json", + }, + body: body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Complete OAuth flow + */ + async authenticate(providerName, buildAuthUrlFn) { + // Generate PKCE + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + // Start local server and get redirect URI + const { redirectUri, waitForCallback } = await this.startAuthFlow(null, providerName); + + // Build authorization URL + const authUrl = buildAuthUrlFn(redirectUri, state, codeChallenge); + + console.log(`\nOpening browser for ${providerName} authentication...`); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + const callbackParams = await waitForCallback(); + + // Validate state + if (callbackParams.state !== state) { + throw new Error("Invalid state parameter"); + } + + return { + code: callbackParams.code, + state: callbackParams.state, + codeVerifier, + redirectUri, + }; + } +} diff --git a/src/lib/oauth/services/openai.js b/src/lib/oauth/services/openai.js new file mode 100644 index 0000000000..8e260b659e --- /dev/null +++ b/src/lib/oauth/services/openai.js @@ -0,0 +1,122 @@ +import { OAuthService } from "./oauth.js"; +import { OPENAI_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * OpenAI OAuth Service (Native) + * Uses Authorization Code Flow with PKCE (similar to Codex) + */ +export class OpenAIService extends OAuthService { + constructor() { + super(OPENAI_CONFIG); + } + + /** + * Build OpenAI authorization URL + */ + buildOpenAIAuthUrl(redirectUri, state, codeChallenge) { + const params = new URLSearchParams({ + client_id: OPENAI_CONFIG.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: OPENAI_CONFIG.scope, + state: state, + code_challenge: codeChallenge, + code_challenge_method: OPENAI_CONFIG.codeChallengeMethod, + ...OPENAI_CONFIG.extraParams, + }); + + return `${OPENAI_CONFIG.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange OpenAI authorization code for tokens + */ + async exchangeOpenAICode(code, redirectUri, codeVerifier) { + const response = await fetch(OPENAI_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CONFIG.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Save OpenAI tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/openai`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + idToken: tokens.id_token, + scope: tokens.scope, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete OpenAI OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting OpenAI OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Authenticate and get authorization code + const { code, codeVerifier, redirectUri } = await this.authenticate( + "OpenAI", + this.buildOpenAIAuthUrl.bind(this) + ); + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeOpenAICode(code, redirectUri, codeVerifier); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("OpenAI connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/qwen.js b/src/lib/oauth/services/qwen.js new file mode 100644 index 0000000000..26c63161ee --- /dev/null +++ b/src/lib/oauth/services/qwen.js @@ -0,0 +1,169 @@ +import open from "open"; +import { QWEN_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Qwen OAuth Service + * Uses Device Code Flow with PKCE + */ +export class QwenService { + constructor() { + this.config = QWEN_CONFIG; + } + + /** + * Request device code + */ + async requestDeviceCode(codeChallenge) { + const response = await fetch(this.config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: this.config.clientId, + scope: this.config.scope, + code_challenge: codeChallenge, + code_challenge_method: this.config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + } + + /** + * Poll for token + */ + async pollForToken(deviceCode, codeVerifier, interval = 5) { + const maxAttempts = 60; // 5 minutes + const pollInterval = interval * 1000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((r) => setTimeout(r, pollInterval)); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: this.config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + if (response.ok) { + return await response.json(); + } + + const error = await response.json(); + + if (error.error === "authorization_pending") { + continue; + } else if (error.error === "slow_down") { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } else if (error.error === "expired_token") { + throw new Error("Device code expired"); + } else if (error.error === "access_denied") { + throw new Error("Access denied"); + } else { + throw new Error(error.error_description || error.error); + } + } + + throw new Error("Authorization timeout"); + } + + /** + * Save Qwen tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/qwen`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + resourceUrl: tokens.resource_url, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Qwen OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Qwen OAuth...").start(); + + try { + spinner.text = "Generating PKCE..."; + + // Generate PKCE + const { codeVerifier, codeChallenge } = generatePKCE(); + + spinner.text = "Requesting device code..."; + + // Request device code + const deviceData = await this.requestDeviceCode(codeChallenge); + + spinner.stop(); + + console.log("\n📋 Please visit the following URL and enter the code:\n"); + console.log(` ${deviceData.verification_uri}\n`); + console.log(` Code: ${deviceData.user_code}\n`); + + // Open browser + if (deviceData.verification_uri_complete) { + await open(deviceData.verification_uri_complete); + } else { + await open(deviceData.verification_uri); + } + + spinner.start("Waiting for authorization..."); + + // Poll for token + const tokens = await this.pollForToken( + deviceData.device_code, + codeVerifier, + deviceData.interval || 5 + ); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Qwen connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/utils/banner.js b/src/lib/oauth/utils/banner.js new file mode 100644 index 0000000000..02655550c4 --- /dev/null +++ b/src/lib/oauth/utils/banner.js @@ -0,0 +1,62 @@ +import figlet from "figlet"; +import gradient from "gradient-string"; +import chalkAnimation from "chalk-animation"; + +/** + * Display banner + */ +export function showBanner() { + const banner = figlet.textSync("LLM Proxy", { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }); + + console.log("\n" + gradient.pastel.multiline(banner)); + console.log(gradient.cristal(" 🚀 OAuth CLI for AI Providers\n")); +} + +/** + * Display simple banner (no animation) + */ +export function showSimpleBanner() { + const banner = figlet.textSync("EP CLI", { + font: "Standard", + horizontalLayout: "default", + }); + console.log(gradient.pastel.multiline(banner)); + console.log(gradient.cristal(" OAuth CLI for AI Providers\n")); +} + +/** + * Display success animation + */ +export async function showSuccess(message) { + return new Promise((resolve) => { + const animation = chalkAnimation.rainbow(`\n✨ ${message}\n`); + setTimeout(() => { + animation.stop(); + resolve(); + }, 1000); + }); +} + +/** + * Display loading animation + */ +export function showLoading(text) { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + + const interval = setInterval(() => { + process.stdout.write(`\r${frames[i]} ${text}`); + i = (i + 1) % frames.length; + }, 80); + + return { + stop: () => { + clearInterval(interval); + process.stdout.write("\r"); + }, + }; +} diff --git a/src/lib/oauth/utils/pkce.js b/src/lib/oauth/utils/pkce.js new file mode 100644 index 0000000000..7c258caccb --- /dev/null +++ b/src/lib/oauth/utils/pkce.js @@ -0,0 +1,37 @@ +import crypto from "crypto"; + +/** + * Generate PKCE code verifier (43-128 characters) + */ +export function generateCodeVerifier() { + return crypto.randomBytes(32).toString("base64url"); +} + +/** + * Generate PKCE code challenge from verifier (S256 method) + */ +export function generateCodeChallenge(verifier) { + return crypto.createHash("sha256").update(verifier).digest("base64url"); +} + +/** + * Generate random state for CSRF protection + */ +export function generateState() { + return crypto.randomBytes(32).toString("base64url"); +} + +/** + * Generate complete PKCE pair + */ +export function generatePKCE() { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + return { + codeVerifier, + codeChallenge, + state, + }; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js new file mode 100644 index 0000000000..c4946f968b --- /dev/null +++ b/src/lib/oauth/utils/server.js @@ -0,0 +1,119 @@ +import http from "http"; +import { URL } from "url"; + +/** + * Start a local HTTP server to receive OAuth callback + * @param {Function} onCallback - Called with query params when callback received + * @param {number} fixedPort - Optional fixed port number (default: random) + * @returns {Promise<{server: http.Server, port: number, close: Function}>} + */ +export function startLocalServer(onCallback, fixedPort = null) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + + if (url.pathname === "/callback" || url.pathname === "/auth/callback") { + const params = Object.fromEntries(url.searchParams); + + // Send success response to browser with auto-close attempt + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(` + + + + Authentication Successful + + + +
+
+

Authentication Successful

+

Closing in 3 seconds...

+
+ + +`); + + // Call callback with params + onCallback(params); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + // Listen on fixed port or find available port + const portToUse = fixedPort || 0; + server.listen(portToUse, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + server, + port, + close: () => server.close(), + }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE" && fixedPort) { + reject( + new Error( + `Port ${fixedPort} is already in use. Please close other applications using this port.` + ) + ); + } else { + reject(err); + } + }); + }); +} + +/** + * Wait for callback with timeout + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} - Callback params + */ +export function waitForCallback(timeoutMs = 300000) { + return new Promise((resolve, reject) => { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error("Authentication timeout")); + } + }, timeoutMs); + + const onCallback = (params) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(params); + } + }; + + // Return the callback function + resolve.__onCallback = onCallback; + }); +} diff --git a/src/lib/oauth/utils/ui.js b/src/lib/oauth/utils/ui.js new file mode 100644 index 0000000000..78d517eeaf --- /dev/null +++ b/src/lib/oauth/utils/ui.js @@ -0,0 +1,47 @@ +import chalk from "chalk"; +import ora from "ora"; + +/** + * UI Helper Functions + */ + +export function success(message) { + console.log(chalk.green(`\n✓ ${message}\n`)); +} + +export function error(message) { + console.log(chalk.red(`\n✗ ${message}\n`)); +} + +export function info(message) { + console.log(chalk.blue(`\n${message}\n`)); +} + +export function warn(message) { + console.log(chalk.yellow(`\n⚠ ${message}\n`)); +} + +export function gray(message) { + console.log(chalk.gray(message)); +} + +export function spinner(text) { + return ora(text); +} + +export function printSection(title) { + console.log(chalk.blue(`\n${title}\n`)); +} + +export function printKeyValue(key, value, isSuccess = false) { + const color = isSuccess ? chalk.green : chalk.gray; + console.log(color(` ${key}: ${value}`)); +} + +export function printList(items, isSuccess = false) { + const symbol = isSuccess ? "✓" : "✗"; + const color = isSuccess ? chalk.green : chalk.gray; + items.forEach((item) => { + console.log(color(` ${symbol} ${item}`)); + }); +} diff --git a/src/lib/providers/validation.js b/src/lib/providers/validation.js new file mode 100644 index 0000000000..8d7acf8722 --- /dev/null +++ b/src/lib/providers/validation.js @@ -0,0 +1,314 @@ +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.js"; +import { + isAnthropicCompatibleProvider, + isOpenAICompatibleProvider, +} from "@/shared/constants/providers"; + +const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]); +const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]); + +function normalizeBaseUrl(baseUrl) { + return (baseUrl || "").trim().replace(/\/$/, ""); +} + +function addModelsSuffix(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return ""; + + const suffixes = ["/chat/completions", "/responses", "/chat", "/messages"]; + for (const suffix of suffixes) { + if (normalized.endsWith(suffix)) { + return `${normalized.slice(0, -suffix.length)}/models`; + } + } + + return `${normalized}/models`; +} + +function resolveBaseUrl(entry, providerSpecificData = {}) { + if (providerSpecificData?.baseUrl) return normalizeBaseUrl(providerSpecificData.baseUrl); + if (entry?.baseUrl) return normalizeBaseUrl(entry.baseUrl); + return ""; +} + +function resolveChatUrl(provider, baseUrl, providerSpecificData = {}) { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return ""; + + if (isOpenAICompatibleProvider(provider)) { + if (providerSpecificData?.apiType === "responses") { + return `${normalized}/responses`; + } + return `${normalized}/chat/completions`; + } + + if ( + normalized.endsWith("/chat/completions") || + normalized.endsWith("/responses") || + normalized.endsWith("/chat") + ) { + return normalized; + } + + if (normalized.endsWith("/v1")) { + return `${normalized}/chat/completions`; + } + + return normalized; +} + +function buildBearerHeaders(apiKey) { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; +} + +async function validateOpenAILikeProvider({ + provider, + apiKey, + baseUrl, + providerSpecificData = {}, + modelId = "gpt-4o-mini", +}) { + if (!baseUrl) { + return { valid: false, error: "Missing base URL" }; + } + + const modelsUrl = addModelsSuffix(baseUrl); + if (!modelsUrl) { + return { valid: false, error: "Invalid models endpoint" }; + } + + const modelsRes = await fetch(modelsUrl, { + method: "GET", + headers: buildBearerHeaders(apiKey), + }); + + if (modelsRes.ok) { + return { valid: true, error: null }; + } + + if (modelsRes.status === 401 || modelsRes.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData); + if (!chatUrl) { + return { valid: false, error: `Validation failed: ${modelsRes.status}` }; + } + + const testBody = { + model: modelId, + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }; + + const chatRes = await fetch(chatUrl, { + method: "POST", + headers: buildBearerHeaders(apiKey), + body: JSON.stringify(testBody), + }); + + if (chatRes.ok) { + return { valid: true, error: null }; + } + + if (chatRes.status === 401 || chatRes.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + if (chatRes.status === 404 || chatRes.status === 405) { + return { valid: false, error: "Provider validation endpoint not supported" }; + } + + if (chatRes.status >= 500) { + return { valid: false, error: `Provider unavailable (${chatRes.status})` }; + } + + // 4xx other than auth (e.g., invalid model/body) usually means auth passed. + return { valid: true, error: null }; +} + +async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers = {} }) { + if (!baseUrl) { + return { valid: false, error: "Missing base URL" }; + } + + const requestHeaders = { + "Content-Type": "application/json", + ...headers, + }; + + if (!requestHeaders["x-api-key"] && !requestHeaders["X-API-Key"]) { + requestHeaders["x-api-key"] = apiKey; + } + + if (!requestHeaders["anthropic-version"] && !requestHeaders["Anthropic-Version"]) { + requestHeaders["anthropic-version"] = "2023-06-01"; + } + + const response = await fetch(baseUrl, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ + model: modelId || "claude-3-5-sonnet-20241022", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: true, error: null }; +} + +async function validateGeminiLikeProvider({ apiKey, baseUrl }) { + if (!baseUrl) { + return { valid: false, error: "Missing base URL" }; + } + + const separator = baseUrl.includes("?") ? "&" : "?"; + const response = await fetch(`${baseUrl}${separator}key=${encodeURIComponent(apiKey)}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (response.ok) { + return { valid: true, error: null }; + } + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `Validation failed: ${response.status}` }; +} + +async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }) { + const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl); + if (!baseUrl) { + return { valid: false, error: "No base URL configured for OpenAI compatible provider" }; + } + + const response = await fetch(`${baseUrl}/models`, { + method: "GET", + headers: buildBearerHeaders(apiKey), + }); + + if (response.ok) { + return { valid: true, error: null }; + } + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `Validation failed: ${response.status}` }; +} + +async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }) { + let baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl); + if (!baseUrl) { + return { valid: false, error: "No base URL configured for Anthropic compatible provider" }; + } + + if (baseUrl.endsWith("/messages")) { + baseUrl = baseUrl.slice(0, -9); + } + + const response = await fetch(`${baseUrl}/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return { valid: true, error: null }; + } + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `Validation failed: ${response.status}` }; +} + +export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }) { + if (!provider || !apiKey) { + return { valid: false, error: "Provider and API key required", unsupported: false }; + } + + if (isOpenAICompatibleProvider(provider)) { + try { + return await validateOpenAICompatibleProvider({ apiKey, providerSpecificData }); + } catch (error) { + return { valid: false, error: error.message || "Validation failed", unsupported: false }; + } + } + + if (isAnthropicCompatibleProvider(provider)) { + try { + return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData }); + } catch (error) { + return { valid: false, error: error.message || "Validation failed", unsupported: false }; + } + } + + const entry = getRegistryEntry(provider); + if (!entry) { + return { valid: false, error: "Provider validation not supported", unsupported: true }; + } + + const modelId = entry.models?.[0]?.id || null; + const baseUrl = resolveBaseUrl(entry, providerSpecificData); + + try { + if (OPENAI_LIKE_FORMATS.has(entry.format)) { + return await validateOpenAILikeProvider({ + provider, + apiKey, + baseUrl, + providerSpecificData, + modelId, + }); + } + + if (entry.format === "claude") { + const requestBaseUrl = `${baseUrl}${entry.urlSuffix || ""}`; + const requestHeaders = { + ...(entry.headers || {}), + }; + + if ((entry.authHeader || "").toLowerCase() === "x-api-key") { + requestHeaders["x-api-key"] = apiKey; + } else { + requestHeaders["Authorization"] = `Bearer ${apiKey}`; + } + + return await validateAnthropicLikeProvider({ + apiKey, + baseUrl: requestBaseUrl, + modelId, + headers: requestHeaders, + }); + } + + if (GEMINI_LIKE_FORMATS.has(entry.format)) { + return await validateGeminiLikeProvider({ + apiKey, + baseUrl, + }); + } + + return { valid: false, error: "Provider validation not supported", unsupported: true }; + } catch (error) { + return { valid: false, error: error.message || "Validation failed", unsupported: false }; + } +} diff --git a/src/lib/proxyLogger.js b/src/lib/proxyLogger.js new file mode 100644 index 0000000000..e69b8d7f80 --- /dev/null +++ b/src/lib/proxyLogger.js @@ -0,0 +1,122 @@ +/** + * Proxy Logger — In-memory ring buffer for proxy events + * Mirrors the call-log pattern used by RequestLoggerV2 + */ +import { v4 as uuidv4 } from "uuid"; + +const MAX_ENTRIES = 500; +const proxyLogs = []; + +/** + * Log a proxy event + * @param {Object} entry + * @param {"success"|"error"|"timeout"} entry.status + * @param {Object} entry.proxy - { type, host, port } + * @param {"key"|"combo"|"provider"|"global"|"direct"} entry.level + * @param {string} [entry.levelId] + * @param {string} [entry.provider] + * @param {string} [entry.targetUrl] + * @param {string} [entry.publicIp] + * @param {number} [entry.latencyMs] + * @param {string} [entry.error] + * @param {string} [entry.connectionId] + * @param {string} [entry.comboId] + */ +export function logProxyEvent(entry) { + const log = { + id: uuidv4(), + timestamp: new Date().toISOString(), + status: entry.status || "success", + proxy: entry.proxy || null, + level: entry.level || "direct", + levelId: entry.levelId || null, + provider: entry.provider || null, + targetUrl: entry.targetUrl || null, + publicIp: entry.publicIp || null, + latencyMs: entry.latencyMs || 0, + error: entry.error || null, + connectionId: entry.connectionId || null, + comboId: entry.comboId || null, + account: entry.account || null, + }; + + proxyLogs.unshift(log); // newest first + + // Trim to max + if (proxyLogs.length > MAX_ENTRIES) { + proxyLogs.length = MAX_ENTRIES; + } + + return log; +} + +/** + * Get proxy logs with optional filters + * @param {Object} filters + * @param {string} [filters.status] - "success"|"error"|"timeout" + * @param {string} [filters.type] - "http"|"https"|"socks5" + * @param {string} [filters.provider] + * @param {string} [filters.level] - "key"|"combo"|"provider"|"global"|"direct" + * @param {string} [filters.search] - free text search + * @param {number} [filters.limit] - max results (default 300) + * @returns {Array} + */ +export function getProxyLogs(filters = {}) { + let logs = [...proxyLogs]; + + if (filters.status) { + if (filters.status === "ok") { + logs = logs.filter((l) => l.status === "success"); + } else { + logs = logs.filter((l) => l.status === filters.status); + } + } + + if (filters.type) { + logs = logs.filter((l) => l.proxy?.type === filters.type); + } + + if (filters.provider) { + logs = logs.filter((l) => l.provider === filters.provider); + } + + if (filters.level) { + logs = logs.filter((l) => l.level === filters.level); + } + + if (filters.search) { + const q = filters.search.toLowerCase(); + logs = logs.filter( + (l) => + (l.proxy?.host || "").toLowerCase().includes(q) || + (l.provider || "").toLowerCase().includes(q) || + (l.targetUrl || "").toLowerCase().includes(q) || + (l.publicIp || "").toLowerCase().includes(q) || + (l.level || "").toLowerCase().includes(q) || + (l.error || "").toLowerCase().includes(q) || + (l.account || "").toLowerCase().includes(q) + ); + } + + const limit = filters.limit || 300; + return logs.slice(0, limit); +} + +/** + * Clear all proxy logs + */ +export function clearProxyLogs() { + proxyLogs.length = 0; +} + +/** + * Get proxy log stats + */ +export function getProxyLogStats() { + const total = proxyLogs.length; + const success = proxyLogs.filter((l) => l.status === "success").length; + const error = proxyLogs.filter((l) => l.status === "error").length; + const timeout = proxyLogs.filter((l) => l.status === "timeout").length; + const direct = proxyLogs.filter((l) => l.level === "direct").length; + return { total, success, error, timeout, direct }; +} diff --git a/src/lib/tokenHealthCheck.js b/src/lib/tokenHealthCheck.js new file mode 100644 index 0000000000..d9551f2264 --- /dev/null +++ b/src/lib/tokenHealthCheck.js @@ -0,0 +1,160 @@ +/** + * Proactive Token Health Check Scheduler + * + * Background job that periodically refreshes OAuth tokens before they expire. + * Each connection can configure its own `healthCheckInterval` (minutes). + * Default: 60 minutes. 0 = disabled. + * + * The scheduler runs a lightweight sweep every TICK_MS (60 s). + * For each eligible connection it calls the provider-specific refresh function, + * updates the DB, and logs the result. + */ + +import { getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { getAccessToken, supportsTokenRefresh } from "@omniroute/open-sse/services/tokenRefresh.js"; + +// ── Constants ──────────────────────────────────────────────────────────────── +const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds +const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval +const LOG_PREFIX = "[HealthCheck]"; + +// ── Singleton guard ────────────────────────────────────────────────────────── +let initialized = false; +let intervalHandle = null; + +/** + * Start the health-check scheduler (idempotent). + */ +export function initTokenHealthCheck() { + if (initialized) return; + initialized = true; + + console.log( + `${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)` + ); + + // Run first sweep after a short delay so the server finishes booting + setTimeout(() => { + sweep(); + intervalHandle = setInterval(sweep, TICK_MS); + }, 10_000); +} + +/** + * Stop the scheduler (useful for tests / hot-reload). + */ +export function stopTokenHealthCheck() { + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } + initialized = false; +} + +// ── Core sweep ─────────────────────────────────────────────────────────────── +async function sweep() { + try { + const connections = await getProviderConnections({ authType: "oauth" }); + + if (!connections || connections.length === 0) return; + + for (const conn of connections) { + try { + await checkConnection(conn); + } catch (err) { + // Per-connection isolation: one failure never blocks others + console.error(`${LOG_PREFIX} Error checking ${conn.name || conn.id}:`, err.message); + } + } + } catch (err) { + console.error(`${LOG_PREFIX} Sweep error:`, err.message); + } +} + +/** + * Check a single connection and refresh if due. + */ +async function checkConnection(conn) { + // Determine interval (0 = disabled) + const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN; + if (intervalMin <= 0) return; + if (!conn.isActive) return; + if (!conn.refreshToken || typeof conn.refreshToken !== "string") return; + if (!supportsTokenRefresh(conn.provider)) { + const now = new Date().toISOString(); + await updateProviderConnection(conn.id, { lastHealthCheckAt: now }); + console.log( + `${LOG_PREFIX} Skipping ${conn.provider}/${conn.name || conn.email || conn.id} (refresh unsupported)` + ); + return; + } + + const intervalMs = intervalMin * 60 * 1000; + const lastCheck = conn.lastHealthCheckAt ? new Date(conn.lastHealthCheckAt).getTime() : 0; + + // Not yet due + if (Date.now() - lastCheck < intervalMs) return; + + console.log( + `${LOG_PREFIX} Refreshing ${conn.provider}/${conn.name || conn.email || conn.id} (interval: ${intervalMin}min)` + ); + + const credentials = { + refreshToken: conn.refreshToken, + accessToken: conn.accessToken, + expiresAt: conn.tokenExpiresAt, + providerSpecificData: conn.providerSpecificData, + }; + + const result = await getAccessToken(conn.provider, credentials, { + info: (tag, msg) => console.log(`${LOG_PREFIX} [${tag}] ${msg}`), + warn: (tag, msg) => console.warn(`${LOG_PREFIX} [${tag}] ${msg}`), + error: (tag, msg, extra) => console.error(`${LOG_PREFIX} [${tag}] ${msg}`, extra || ""), + }); + + const now = new Date().toISOString(); + + if (result && result.accessToken) { + // Token refreshed successfully — update DB + const updateData = { + accessToken: result.accessToken, + lastHealthCheckAt: now, + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + }; + + if (result.refreshToken) { + updateData.refreshToken = result.refreshToken; + } + + if (result.expiresIn) { + updateData.tokenExpiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString(); + } + + await updateProviderConnection(conn.id, updateData); + console.log(`${LOG_PREFIX} ✓ ${conn.provider}/${conn.name || conn.email || conn.id} refreshed`); + } else { + // Refresh failed — record but don't disable the connection + await updateProviderConnection(conn.id, { + lastHealthCheckAt: now, + testStatus: "error", + lastError: "Health check: token refresh failed", + lastErrorAt: now, + lastErrorType: "token_refresh_failed", + lastErrorSource: "oauth", + errorCode: "refresh_failed", + }); + console.warn( + `${LOG_PREFIX} ✗ ${conn.provider}/${conn.name || conn.email || conn.id} refresh failed` + ); + } +} + +// Auto-start when imported +initTokenHealthCheck(); + +export default initTokenHealthCheck; diff --git a/src/lib/translatorEvents.js b/src/lib/translatorEvents.js new file mode 100644 index 0000000000..e1f618e1d1 --- /dev/null +++ b/src/lib/translatorEvents.js @@ -0,0 +1,40 @@ +const MAX_TRANSLATION_EVENTS = 200; +const DEFAULT_TRANSLATION_EVENT_LIMIT = 50; + +function ensureEventsBuffer() { + if (!globalThis.__translatorEvents) { + globalThis.__translatorEvents = []; + } + return globalThis.__translatorEvents; +} + +export function logTranslationEvent(event) { + if (!event || typeof event !== "object") return; + + const events = ensureEventsBuffer(); + events.unshift({ + id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + ...event, + }); + + if (events.length > MAX_TRANSLATION_EVENTS) { + events.length = MAX_TRANSLATION_EVENTS; + } +} + +export function getTranslationEvents(limit = DEFAULT_TRANSLATION_EVENT_LIMIT) { + const numericLimit = Number(limit); + const boundedLimit = + Number.isFinite(numericLimit) && numericLimit > 0 + ? Math.min(Math.floor(numericLimit), MAX_TRANSLATION_EVENTS) + : DEFAULT_TRANSLATION_EVENT_LIMIT; + + const events = ensureEventsBuffer(); + return { + events: events.slice(0, boundedLimit), + total: events.length, + }; +} + +export { MAX_TRANSLATION_EVENTS }; diff --git a/src/lib/usage/fetcher.js b/src/lib/usage/fetcher.js new file mode 100644 index 0000000000..a87f93d8d5 --- /dev/null +++ b/src/lib/usage/fetcher.js @@ -0,0 +1,209 @@ +/** + * Usage Fetcher - Get usage data from provider APIs + */ + +import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth"; + +/** + * 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 "qwen": + return await getQwenUsage(accessToken, providerSpecificData); + case "iflow": + return await getIflowUsage(accessToken); + default: + return { message: `Usage API not implemented for ${provider}` }; + } +} + +/** + * GitHub Copilot Usage + */ +async function getGitHubUsage(accessToken, providerSpecificData) { + try { + // Use copilotToken for copilot_internal API, not GitHub OAuth accessToken + const copilotToken = providerSpecificData?.copilotToken; + if (!copilotToken) { + throw new Error("Copilot token not found. Please refresh token first."); + } + + const response = await fetch("https://api.github.com/copilot_internal/user", { + headers: { + Authorization: `Bearer ${copilotToken}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + 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; + return { + plan: data.copilot_plan, + resetDate: data.quota_reset_date, + quotas: { + chat: formatGitHubQuotaSnapshot(snapshots.chat), + completions: formatGitHubQuotaSnapshot(snapshots.completions), + premium_interactions: formatGitHubQuotaSnapshot(snapshots.premium_interactions), + }, + }; + } else if (data.monthly_quotas || data.limited_user_quotas) { + // Free/limited plan format + const monthlyQuotas = data.monthly_quotas || {}; + const usedQuotas = data.limited_user_quotas || {}; + + 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, + }, + completions: { + used: usedQuotas.completions || 0, + total: monthlyQuotas.completions || 0, + unlimited: false, + }, + }, + }; + } + + 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 Usage + */ +async function getAntigravityUsage(accessToken) { + try { + // Similar to Gemini, uses Google Cloud + return { message: "Antigravity connected. Usage tracked via Google Cloud Console." }; + } catch (error) { + return { message: "Unable to fetch Antigravity usage." }; + } +} + +/** + * Claude Usage + */ +async function getClaudeUsage(accessToken) { + try { + // Claude OAuth doesn't expose usage API directly + // Could potentially check via inference endpoint + return { message: "Claude connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch Claude usage." }; + } +} + +/** + * Codex (OpenAI) Usage + */ +async function getCodexUsage(accessToken) { + try { + // OpenAI usage requires organization API access + return { message: "Codex connected. Check OpenAI dashboard for usage." }; + } catch (error) { + return { message: "Unable to fetch Codex usage." }; + } +} + +/** + * 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." }; + } +} diff --git a/src/lib/usageAnalytics.js b/src/lib/usageAnalytics.js new file mode 100644 index 0000000000..2507a843d8 --- /dev/null +++ b/src/lib/usageAnalytics.js @@ -0,0 +1,318 @@ +/** + * Usage Analytics — Aggregation functions for the analytics dashboard + * + * Processes usage.json history entries into dashboard-ready data: + * summary cards, daily trends, activity heatmap, model breakdown, etc. + */ + +import { calculateCost } from "@/lib/usageDb.js"; + +/** + * Compute date range boundaries + * @param {string} range - "7d" | "30d" | "90d" | "ytd" | "all" + * @returns {{ start: Date, end: Date }} + */ +function getDateRange(range) { + const end = new Date(); + let start; + + switch (range) { + case "7d": + start = new Date(end); + start.setDate(start.getDate() - 7); + break; + case "30d": + start = new Date(end); + start.setDate(start.getDate() - 30); + break; + case "90d": + start = new Date(end); + start.setDate(start.getDate() - 90); + break; + case "ytd": + start = new Date(end.getFullYear(), 0, 1); + break; + case "all": + default: + start = new Date(0); + break; + } + + return { start, end }; +} + +/** + * Format a Date to "YYYY-MM-DD" string + */ +function toDateKey(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** + * Short model name (strip provider prefix paths) + */ +function shortModelName(model) { + if (!model) return "unknown"; + // "accounts/fireworks/models/gpt-oss-120b" → "gpt-oss-120b" + const parts = model.split("/"); + return parts[parts.length - 1] || model; +} + +/** + * Compute all analytics data from usage history + * @param {Array} history - Array of usage entries + * @param {string} range - Time range filter + * @param {Object} connectionMap - Map of connectionId → account name + * @returns {Object} Analytics data + */ +export async function computeAnalytics(history, range = "30d", connectionMap = {}) { + const { start, end } = getDateRange(range); + + // ---- Filtered entries ---- + const entries = history.filter((e) => { + const t = new Date(e.timestamp); + return t >= start && t <= end; + }); + + // ---- Summary ---- + const summary = { + totalTokens: 0, + promptTokens: 0, + completionTokens: 0, + totalCost: 0, + totalRequests: entries.length, + uniqueModels: new Set(), + uniqueAccounts: new Set(), + uniqueApiKeys: new Set(), + }; + + // ---- Daily trend ---- + const dailyMap = {}; // "YYYY-MM-DD" → { requests, promptTokens, completionTokens, cost } + const dailyByModelMap = {}; // "YYYY-MM-DD" → { modelShort → tokens } + + // ---- Activity heatmap (always last 365 days, regardless of range filter) ---- + const heatmapStart = new Date(); + heatmapStart.setDate(heatmapStart.getDate() - 364); + const activityMap = {}; + + // ---- By model / account / provider ---- + const byModelMap = {}; + const byAccountMap = {}; + const byProviderMap = {}; + const byApiKeyMap = {}; + + // ---- Weekly pattern (0=Sun..6=Sat) ---- + const weeklyTokens = [0, 0, 0, 0, 0, 0, 0]; + const weeklyCounts = [0, 0, 0, 0, 0, 0, 0]; + + // ---- Single pass over ALL history for heatmap ---- + for (const entry of history) { + const entryDate = new Date(entry.timestamp); + if (entryDate >= heatmapStart) { + const key = toDateKey(entryDate); + const tokens = + (entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0) + + (entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0); + activityMap[key] = (activityMap[key] || 0) + tokens; + } + } + + // ---- Single pass over filtered entries for everything else ---- + for (const entry of entries) { + const pt = entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0; + const ct = entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0; + const totalTkns = pt + ct; + const entryDate = new Date(entry.timestamp); + const dateKey = toDateKey(entryDate); + const dayOfWeek = entryDate.getDay(); + const modelShort = shortModelName(entry.model); + + // Cost + let cost = 0; + try { + cost = await calculateCost(entry.provider, entry.model, entry.tokens); + } catch { + /* ignore */ + } + + // Summary + summary.promptTokens += pt; + summary.completionTokens += ct; + summary.totalTokens += totalTkns; + summary.totalCost += cost; + if (entry.model) summary.uniqueModels.add(modelShort); + if (entry.connectionId) summary.uniqueAccounts.add(entry.connectionId); + if (entry.apiKeyId || entry.apiKeyName) { + summary.uniqueApiKeys.add(entry.apiKeyId || entry.apiKeyName); + } + + // Daily trend + if (!dailyMap[dateKey]) { + dailyMap[dateKey] = { + date: dateKey, + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + }; + } + dailyMap[dateKey].requests++; + dailyMap[dateKey].promptTokens += pt; + dailyMap[dateKey].completionTokens += ct; + dailyMap[dateKey].cost += cost; + + // Daily by model + if (!dailyByModelMap[dateKey]) dailyByModelMap[dateKey] = {}; + dailyByModelMap[dateKey][modelShort] = (dailyByModelMap[dateKey][modelShort] || 0) + totalTkns; + + // Weekly pattern + weeklyTokens[dayOfWeek] += totalTkns; + weeklyCounts[dayOfWeek]++; + + // By model + if (!byModelMap[modelShort]) { + byModelMap[modelShort] = { + model: modelShort, + provider: entry.provider, + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cost: 0, + }; + } + byModelMap[modelShort].requests++; + byModelMap[modelShort].promptTokens += pt; + byModelMap[modelShort].completionTokens += ct; + byModelMap[modelShort].totalTokens += totalTkns; + byModelMap[modelShort].cost += cost; + + // By account + const accountName = entry.connectionId + ? connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}` + : entry.provider || "unknown"; + if (!byAccountMap[accountName]) { + byAccountMap[accountName] = { account: accountName, totalTokens: 0, requests: 0, cost: 0 }; + } + byAccountMap[accountName].totalTokens += totalTkns; + byAccountMap[accountName].requests++; + byAccountMap[accountName].cost += cost; + + // By provider + const prov = entry.provider || "unknown"; + if (!byProviderMap[prov]) { + byProviderMap[prov] = { + provider: prov, + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cost: 0, + }; + } + byProviderMap[prov].requests++; + byProviderMap[prov].promptTokens += pt; + byProviderMap[prov].completionTokens += ct; + byProviderMap[prov].totalTokens += totalTkns; + byProviderMap[prov].cost += cost; + + // By API key + if (entry.apiKeyId || entry.apiKeyName) { + const keyName = entry.apiKeyName || entry.apiKeyId || "unknown"; + const keyLabel = entry.apiKeyId ? `${keyName} (${entry.apiKeyId})` : keyName; + if (!byApiKeyMap[keyLabel]) { + byApiKeyMap[keyLabel] = { + apiKey: keyLabel, + apiKeyId: entry.apiKeyId || null, + apiKeyName: keyName, + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cost: 0, + }; + } + byApiKeyMap[keyLabel].requests++; + byApiKeyMap[keyLabel].promptTokens += pt; + byApiKeyMap[keyLabel].completionTokens += ct; + byApiKeyMap[keyLabel].totalTokens += totalTkns; + byApiKeyMap[keyLabel].cost += cost; + } + } + + // ---- Build sorted arrays ---- + const dailyTrend = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date)); + + // Daily by model — collect all unique model names + const allModels = new Set(); + for (const day of Object.values(dailyByModelMap)) { + for (const m of Object.keys(day)) allModels.add(m); + } + const dailyByModel = dailyTrend.map((d) => { + const row = { date: d.date }; + for (const m of allModels) { + row[m] = dailyByModelMap[d.date]?.[m] || 0; + } + return row; + }); + + const byModel = Object.values(byModelMap) + .sort((a, b) => b.totalTokens - a.totalTokens) + .map((m) => ({ + ...m, + pct: summary.totalTokens > 0 ? ((m.totalTokens / summary.totalTokens) * 100).toFixed(1) : "0", + })); + + const byAccount = Object.values(byAccountMap).sort((a, b) => b.totalTokens - a.totalTokens); + const byProvider = Object.values(byProviderMap).sort((a, b) => b.totalTokens - a.totalTokens); + const byApiKey = Object.values(byApiKeyMap).sort((a, b) => b.totalTokens - a.totalTokens); + + // Weekly pattern (avg tokens per day of week) + const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const weeklyPattern = weekDays.map((name, i) => ({ + day: name, + avgTokens: weeklyCounts[i] > 0 ? Math.round(weeklyTokens[i] / weeklyCounts[i]) : 0, + totalTokens: weeklyTokens[i], + })); + + // Streak — consecutive days with activity (from today going back) + let streak = 0; + const today = new Date(); + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = toDateKey(d); + if (activityMap[key] && activityMap[key] > 0) { + streak++; + } else if (i > 0) { + break; // Stop at first gap (skip today if no activity yet) + } + } + + return { + summary: { + totalTokens: summary.totalTokens, + promptTokens: summary.promptTokens, + completionTokens: summary.completionTokens, + totalCost: summary.totalCost, + totalRequests: summary.totalRequests, + uniqueModels: summary.uniqueModels.size, + uniqueAccounts: summary.uniqueAccounts.size, + uniqueApiKeys: summary.uniqueApiKeys.size, + streak, + }, + dailyTrend, + dailyByModel, + modelNames: [...allModels], + byModel, + byAccount, + byProvider, + byApiKey, + activityMap, + weeklyPattern, + range, + }; +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js new file mode 100644 index 0000000000..437064efd6 --- /dev/null +++ b/src/lib/usageDb.js @@ -0,0 +1,968 @@ +/** + * usageDb.js — Usage tracking, request logging, and call logs. + * + * P1.2: Migrated from LowDB/JSON to SQLite. + * - usage_history table replaces usage.json + * - call_logs table replaces call_logs.json + * - log.txt and call_logs/ disk files remain file-based + */ + +import path from "path"; +import fs from "fs"; +import { getDbInstance, isCloud, isBuildPhase, DATA_DIR } from "./db/core.js"; +import { resolveDataDir, getLegacyDotDataDir, isSamePath } from "./dataPaths.js"; + +const shouldPersistToDisk = !isCloud && !isBuildPhase; + +// ──────────────── File Paths (log.txt + call_logs/) ──────────────── + +const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); +const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt"); +const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs"); + +// Legacy paths for migration +const LEGACY_DB_FILE = + isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "usage.json"); +const LEGACY_LOG_FILE = isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "log.txt"); +const LEGACY_CALL_LOGS_DB_FILE = + isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs.json"); +const LEGACY_CALL_LOGS_DIR = + isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs"); + +// Current-location JSON files (for migration into SQLite) +const USAGE_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json"); +const CALL_LOGS_JSON_FILE = isCloud ? null : path.join(DATA_DIR, "call_logs.json"); + +// ──────────────── Legacy File Migration ──────────────── + +function copyIfMissing(fromPath, toPath, label) { + if (!fromPath || !toPath) return; + if (!fs.existsSync(fromPath) || fs.existsSync(toPath)) return; + + if (fs.statSync(fromPath).isDirectory()) { + fs.cpSync(fromPath, toPath, { recursive: true }); + } else { + fs.copyFileSync(fromPath, toPath); + } + console.log(`[usageDb] Migrated ${label}: ${fromPath} -> ${toPath}`); +} + +function migrateLegacyUsageFiles() { + if (!shouldPersistToDisk || !LEGACY_DATA_DIR) return; + if (isSamePath(DATA_DIR, LEGACY_DATA_DIR)) return; + + try { + copyIfMissing(LEGACY_DB_FILE, USAGE_JSON_FILE, "usage history"); + copyIfMissing(LEGACY_LOG_FILE, LOG_FILE, "request log"); + copyIfMissing(LEGACY_CALL_LOGS_DB_FILE, CALL_LOGS_JSON_FILE, "call log index"); + copyIfMissing(LEGACY_CALL_LOGS_DIR, CALL_LOGS_DIR, "call log files"); + } catch (error) { + console.error("[usageDb] Legacy migration failed:", error.message); + } +} + +migrateLegacyUsageFiles(); + +// ──────────────── JSON → SQLite Migration ──────────────── + +function migrateUsageJsonToSqlite() { + if (!shouldPersistToDisk) return; + const db = getDbInstance(); + + // 1. Migrate usage.json + if (USAGE_JSON_FILE && fs.existsSync(USAGE_JSON_FILE)) { + try { + const raw = fs.readFileSync(USAGE_JSON_FILE, "utf-8"); + const data = JSON.parse(raw); + const history = data.history || []; + + if (history.length > 0) { + console.log(`[usageDb] Migrating ${history.length} usage entries from JSON → SQLite...`); + + const insert = db.prepare(` + INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, + status, timestamp) + VALUES (@provider, @model, @connectionId, @apiKeyId, @apiKeyName, + @tokensInput, @tokensOutput, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, + @status, @timestamp) + `); + + const tx = db.transaction(() => { + for (const entry of history) { + insert.run({ + provider: entry.provider || null, + model: entry.model || null, + connectionId: entry.connectionId || null, + apiKeyId: entry.apiKeyId || null, + apiKeyName: entry.apiKeyName || null, + tokensInput: entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0, + tokensOutput: entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0, + tokensCacheRead: entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0, + tokensCacheCreation: + entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0, + tokensReasoning: entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0, + status: entry.status || null, + timestamp: entry.timestamp || new Date().toISOString(), + }); + } + }); + tx(); + console.log(`[usageDb] ✓ Migrated ${history.length} usage entries`); + } + + fs.renameSync(USAGE_JSON_FILE, USAGE_JSON_FILE + ".migrated"); + } catch (err) { + console.error("[usageDb] Failed to migrate usage.json:", err.message); + } + } + + // 2. Migrate call_logs.json + if (CALL_LOGS_JSON_FILE && fs.existsSync(CALL_LOGS_JSON_FILE)) { + try { + const raw = fs.readFileSync(CALL_LOGS_JSON_FILE, "utf-8"); + const data = JSON.parse(raw); + const logs = data.logs || []; + + if (logs.length > 0) { + console.log(`[usageDb] Migrating ${logs.length} call log entries from JSON → SQLite...`); + + const insert = db.prepare(` + INSERT OR IGNORE INTO call_logs (id, timestamp, method, path, status, model, provider, + account, connection_id, duration, tokens_in, tokens_out, source_format, target_format, + api_key_id, api_key_name, combo_name, request_body, response_body, error) + VALUES (@id, @timestamp, @method, @path, @status, @model, @provider, + @account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat, + @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error) + `); + + const tx = db.transaction(() => { + for (const log of logs) { + insert.run({ + id: log.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + timestamp: log.timestamp || new Date().toISOString(), + method: log.method || "POST", + path: log.path || null, + status: log.status || 0, + model: log.model || null, + provider: log.provider || null, + account: log.account || null, + connectionId: log.connectionId || null, + duration: log.duration || 0, + tokensIn: log.tokens?.in ?? 0, + tokensOut: log.tokens?.out ?? 0, + sourceFormat: log.sourceFormat || null, + targetFormat: log.targetFormat || null, + apiKeyId: log.apiKeyId || null, + apiKeyName: log.apiKeyName || null, + comboName: log.comboName || null, + requestBody: log.requestBody ? JSON.stringify(log.requestBody) : null, + responseBody: log.responseBody ? JSON.stringify(log.responseBody) : null, + error: log.error || null, + }); + } + }); + tx(); + console.log(`[usageDb] ✓ Migrated ${logs.length} call log entries`); + } + + fs.renameSync(CALL_LOGS_JSON_FILE, CALL_LOGS_JSON_FILE + ".migrated"); + } catch (err) { + console.error("[usageDb] Failed to migrate call_logs.json:", err.message); + } + } +} + +// Run migration on module load +if (shouldPersistToDisk) { + try { + migrateUsageJsonToSqlite(); + } catch { + /* ok */ + } +} + +// ──────────────── Pending Requests (in-memory) ──────────────── + +const pendingRequests = { + byModel: {}, + byAccount: {}, +}; + +/** + * Track a pending request + */ +export function trackPendingRequest(model, provider, connectionId, started) { + const modelKey = provider ? `${model} (${provider})` : model; + + if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0; + pendingRequests.byModel[modelKey] = Math.max( + 0, + pendingRequests.byModel[modelKey] + (started ? 1 : -1) + ); + + if (connectionId) { + if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {}; + if (!pendingRequests.byAccount[connectionId][modelKey]) + pendingRequests.byAccount[connectionId][modelKey] = 0; + pendingRequests.byAccount[connectionId][modelKey] = Math.max( + 0, + pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1) + ); + } +} + +// ──────────────── getUsageDb Shim (backward compat) ──────────────── + +/** + * Returns an object compatible with the old LowDB interface. + * Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`. + */ +export async function getUsageDb() { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all(); + + const history = rows.map((r) => ({ + provider: r.provider, + model: r.model, + connectionId: r.connection_id, + apiKeyId: r.api_key_id, + apiKeyName: r.api_key_name, + tokens: { + input: r.tokens_input, + output: r.tokens_output, + cacheRead: r.tokens_cache_read, + cacheCreation: r.tokens_cache_creation, + reasoning: r.tokens_reasoning, + }, + status: r.status, + timestamp: r.timestamp, + })); + + return { data: { history } }; +} + +// ──────────────── Save Request Usage ──────────────── + +/** + * Save request usage entry to SQLite + */ +export async function saveRequestUsage(entry) { + if (!shouldPersistToDisk) return; + + try { + const db = getDbInstance(); + const timestamp = entry.timestamp || new Date().toISOString(); + + db.prepare( + ` + INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, + status, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + entry.provider || null, + entry.model || null, + entry.connectionId || null, + entry.apiKeyId || null, + entry.apiKeyName || null, + entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0, + entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0, + entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0, + entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0, + entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0, + entry.status || null, + timestamp + ); + } catch (error) { + console.error("Failed to save usage stats:", error); + } +} + +// ──────────────── Get Usage History ──────────────── + +/** + * Get usage history with optional filters + */ +export async function getUsageHistory(filter = {}) { + const db = getDbInstance(); + let sql = "SELECT * FROM usage_history"; + const conditions = []; + const params = {}; + + if (filter.provider) { + conditions.push("provider = @provider"); + params.provider = filter.provider; + } + if (filter.model) { + conditions.push("model = @model"); + params.model = filter.model; + } + if (filter.startDate) { + conditions.push("timestamp >= @startDate"); + params.startDate = new Date(filter.startDate).toISOString(); + } + if (filter.endDate) { + conditions.push("timestamp <= @endDate"); + params.endDate = new Date(filter.endDate).toISOString(); + } + + if (conditions.length > 0) { + sql += " WHERE " + conditions.join(" AND "); + } + sql += " ORDER BY timestamp ASC"; + + const rows = db.prepare(sql).all(params); + return rows.map((r) => ({ + provider: r.provider, + model: r.model, + connectionId: r.connection_id, + apiKeyId: r.api_key_id, + apiKeyName: r.api_key_name, + tokens: { + input: r.tokens_input, + output: r.tokens_output, + cacheRead: r.tokens_cache_read, + cacheCreation: r.tokens_cache_creation, + reasoning: r.tokens_reasoning, + }, + status: r.status, + timestamp: r.timestamp, + })); +} + +// ──────────────── Request Log (log.txt — file-based) ──────────────── + +function formatLogDate(date = new Date()) { + const pad = (n) => String(n).padStart(2, "0"); + const d = pad(date.getDate()); + const m = pad(date.getMonth() + 1); + const y = date.getFullYear(); + const h = pad(date.getHours()); + const min = pad(date.getMinutes()); + const s = pad(date.getSeconds()); + return `${d}-${m}-${y} ${h}:${min}:${s}`; +} + +/** + * Append to log.txt + */ +export async function appendRequestLog({ model, provider, connectionId, tokens, status }) { + if (!shouldPersistToDisk) return; + + try { + const timestamp = formatLogDate(); + const p = provider?.toUpperCase() || "-"; + const m = model || "-"; + + let account = connectionId ? connectionId.slice(0, 8) : "-"; + try { + const { getProviderConnections } = await import("@/lib/localDb.js"); + const connections = await getProviderConnections(); + const conn = connections.find((c) => c.id === connectionId); + if (conn) account = conn.name || conn.email || account; + } catch {} + + const sent = + tokens?.input !== undefined + ? tokens.input + : tokens?.prompt_tokens !== undefined + ? tokens.prompt_tokens + : "-"; + const received = + tokens?.output !== undefined + ? tokens.output + : tokens?.completion_tokens !== undefined + ? tokens.completion_tokens + : "-"; + + const line = `${timestamp} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${status}\n`; + fs.appendFileSync(LOG_FILE, line); + + const content = fs.readFileSync(LOG_FILE, "utf-8"); + const lines = content.trim().split("\n"); + if (lines.length > 200) { + fs.writeFileSync(LOG_FILE, lines.slice(-200).join("\n") + "\n"); + } + } catch (error) { + console.error("Failed to append to log.txt:", error.message); + } +} + +/** + * Get last N lines of log.txt + */ +export async function getRecentLogs(limit = 200) { + if (!shouldPersistToDisk) return []; + if (!fs || typeof fs.existsSync !== "function") return []; + if (!LOG_FILE) return []; + if (!fs.existsSync(LOG_FILE)) return []; + + try { + const content = fs.readFileSync(LOG_FILE, "utf-8"); + const lines = content.trim().split("\n"); + return lines.slice(-limit).reverse(); + } catch (error) { + console.error("[usageDb] Failed to read log.txt:", error.message); + return []; + } +} + +// ──────────────── Calculate Cost ──────────────── + +/** + * Calculate cost for a usage entry (pure function, no DB interaction) + */ +export async function calculateCost(provider, model, tokens) { + if (!tokens || !provider || !model) return 0; + + try { + const { getPricingForModel } = await import("@/lib/localDb.js"); + const pricing = await getPricingForModel(provider, model); + if (!pricing) return 0; + + let cost = 0; + + const inputTokens = tokens.input ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0; + const cachedTokens = + tokens.cacheRead ?? tokens.cached_tokens ?? tokens.cache_read_input_tokens ?? 0; + const nonCachedInput = Math.max(0, inputTokens - cachedTokens); + cost += nonCachedInput * (pricing.input / 1000000); + + if (cachedTokens > 0) { + cost += cachedTokens * ((pricing.cached || pricing.input) / 1000000); + } + + const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0; + cost += outputTokens * (pricing.output / 1000000); + + const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0; + if (reasoningTokens > 0) { + cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000); + } + + const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0; + if (cacheCreationTokens > 0) { + cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000); + } + + return cost; + } catch (error) { + console.error("Error calculating cost:", error); + return 0; + } +} + +// ──────────────── Usage Stats ──────────────── + +/** + * Get aggregated usage stats + */ +export async function getUsageStats() { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all(); + + const { getProviderConnections } = await import("@/lib/localDb.js"); + let allConnections = []; + try { + allConnections = await getProviderConnections(); + } catch {} + + const connectionMap = {}; + for (const conn of allConnections) { + connectionMap[conn.id] = conn.name || conn.email || conn.id; + } + + const stats = { + totalRequests: rows.length, + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalCost: 0, + byProvider: {}, + byModel: {}, + byAccount: {}, + byApiKey: {}, + last10Minutes: [], + pending: pendingRequests, + activeRequests: [], + }; + + // Build active requests + for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { + for (const [modelKey, count] of Object.entries(models)) { + if (count > 0) { + const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; + const match = modelKey.match(/^(.*) \((.*)\)$/); + stats.activeRequests.push({ + model: match ? match[1] : modelKey, + provider: match ? match[2] : "unknown", + account: accountName, + count, + }); + } + } + } + + // 10-minute buckets + const now = new Date(); + const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000); + const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000); + + const bucketMap = {}; + for (let i = 0; i < 10; i++) { + const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000); + const bucketKey = bucketTime.getTime(); + bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + stats.last10Minutes.push(bucketMap[bucketKey]); + } + + for (const row of rows) { + const promptTokens = row.tokens_input || 0; + const completionTokens = row.tokens_output || 0; + const entryTime = new Date(row.timestamp); + + const entryTokens = { + input: row.tokens_input, + output: row.tokens_output, + cacheRead: row.tokens_cache_read, + cacheCreation: row.tokens_cache_creation, + reasoning: row.tokens_reasoning, + }; + const entryCost = await calculateCost(row.provider, row.model, entryTokens); + + stats.totalPromptTokens += promptTokens; + stats.totalCompletionTokens += completionTokens; + stats.totalCost += entryCost; + + // 10-min buckets + if (entryTime >= tenMinutesAgo && entryTime <= now) { + const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000; + if (bucketMap[entryMinuteStart]) { + bucketMap[entryMinuteStart].requests++; + bucketMap[entryMinuteStart].promptTokens += promptTokens; + bucketMap[entryMinuteStart].completionTokens += completionTokens; + bucketMap[entryMinuteStart].cost += entryCost; + } + } + + // By Provider + if (!stats.byProvider[row.provider]) { + stats.byProvider[row.provider] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + }; + } + stats.byProvider[row.provider].requests++; + stats.byProvider[row.provider].promptTokens += promptTokens; + stats.byProvider[row.provider].completionTokens += completionTokens; + stats.byProvider[row.provider].cost += entryCost; + + // By Model + const modelKey = row.provider ? `${row.model} (${row.provider})` : row.model; + if (!stats.byModel[modelKey]) { + stats.byModel[modelKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + rawModel: row.model, + provider: row.provider, + lastUsed: row.timestamp, + }; + } + stats.byModel[modelKey].requests++; + stats.byModel[modelKey].promptTokens += promptTokens; + stats.byModel[modelKey].completionTokens += completionTokens; + stats.byModel[modelKey].cost += entryCost; + if (new Date(row.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) { + stats.byModel[modelKey].lastUsed = row.timestamp; + } + + // By Account + if (row.connection_id) { + const accountName = + connectionMap[row.connection_id] || `Account ${row.connection_id.slice(0, 8)}...`; + const accountKey = `${row.model} (${row.provider} - ${accountName})`; + if (!stats.byAccount[accountKey]) { + stats.byAccount[accountKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + rawModel: row.model, + provider: row.provider, + connectionId: row.connection_id, + accountName, + lastUsed: row.timestamp, + }; + } + stats.byAccount[accountKey].requests++; + stats.byAccount[accountKey].promptTokens += promptTokens; + stats.byAccount[accountKey].completionTokens += completionTokens; + stats.byAccount[accountKey].cost += entryCost; + if (new Date(row.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) { + stats.byAccount[accountKey].lastUsed = row.timestamp; + } + } + + // By API key + if (row.api_key_id || row.api_key_name) { + const keyName = row.api_key_name || row.api_key_id || "unknown"; + const keyId = row.api_key_id || null; + const apiKey = keyId ? `${keyName} (${keyId})` : keyName; + if (!stats.byApiKey[apiKey]) { + stats.byApiKey[apiKey] = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + cost: 0, + apiKeyId: keyId, + apiKeyName: keyName, + lastUsed: row.timestamp, + }; + } + stats.byApiKey[apiKey].requests++; + stats.byApiKey[apiKey].promptTokens += promptTokens; + stats.byApiKey[apiKey].completionTokens += completionTokens; + stats.byApiKey[apiKey].cost += entryCost; + if (new Date(row.timestamp) > new Date(stats.byApiKey[apiKey].lastUsed)) { + stats.byApiKey[apiKey].lastUsed = row.timestamp; + } + } + } + + return stats; +} + +// ============================================================================ +// Call Logs — Structured logs for the Logger UI +// ============================================================================ + +const CALL_LOGS_MAX = 500; + +let logIdCounter = 0; +function generateLogId() { + logIdCounter++; + return `${Date.now()}-${logIdCounter}`; +} + +/** + * Save a structured call log entry + */ +export async function saveCallLog(entry) { + if (!shouldPersistToDisk) return; + + try { + // Resolve account name + let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-"; + try { + const { getProviderConnections } = await import("@/lib/localDb.js"); + const connections = await getProviderConnections(); + const conn = connections.find((c) => c.id === entry.connectionId); + if (conn) account = conn.name || conn.email || account; + } catch {} + + // Truncate large payloads for DB storage (keep under 8KB each) + const truncatePayload = (obj) => { + if (!obj) return null; + const str = JSON.stringify(obj); + if (str.length <= 8192) return str; + try { + return JSON.stringify({ + _truncated: true, + _originalSize: str.length, + _preview: str.slice(0, 8192) + "...", + }); + } catch { + return JSON.stringify({ _truncated: true }); + } + }; + + const logEntry = { + id: generateLogId(), + timestamp: new Date().toISOString(), + method: entry.method || "POST", + path: entry.path || "/v1/chat/completions", + status: entry.status || 0, + model: entry.model || "-", + provider: entry.provider || "-", + account, + connectionId: entry.connectionId || null, + duration: entry.duration || 0, + tokensIn: entry.tokens?.prompt_tokens || 0, + tokensOut: entry.tokens?.completion_tokens || 0, + sourceFormat: entry.sourceFormat || null, + targetFormat: entry.targetFormat || null, + apiKeyId: entry.apiKeyId || null, + apiKeyName: entry.apiKeyName || null, + comboName: entry.comboName || null, + requestBody: truncatePayload(entry.requestBody), + responseBody: truncatePayload(entry.responseBody), + error: entry.error || null, + }; + + // 1. Insert into SQLite + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, + account, connection_id, duration, tokens_in, tokens_out, source_format, target_format, + api_key_id, api_key_name, combo_name, request_body, response_body, error) + VALUES (@id, @timestamp, @method, @path, @status, @model, @provider, + @account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat, + @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error) + ` + ).run(logEntry); + + // 2. Trim old entries beyond CALL_LOGS_MAX + const count = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get()?.cnt || 0; + if (count > CALL_LOGS_MAX) { + db.prepare( + ` + DELETE FROM call_logs WHERE id IN ( + SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ? + ) + ` + ).run(count - CALL_LOGS_MAX); + } + + // 3. Write full payload to disk file (untruncated) + writeCallLogToDisk( + { ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } }, + entry.requestBody, + entry.responseBody + ); + } catch (error) { + console.error("[callLogs] Failed to save call log:", error.message); + } +} + +/** + * Write call log as JSON file to disk (full payloads, not truncated) + */ +function writeCallLogToDisk(logEntry, requestBody, responseBody) { + if (!CALL_LOGS_DIR) return; + + try { + const now = new Date(); + const dateFolder = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + const dir = path.join(CALL_LOGS_DIR, dateFolder); + + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const safeModel = (logEntry.model || "unknown").replace(/[/:]/g, "-"); + const time = `${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const filename = `${time}_${safeModel}_${logEntry.status}.json`; + + const fullEntry = { + ...logEntry, + requestBody: requestBody || null, + responseBody: responseBody || null, + }; + + fs.writeFileSync(path.join(dir, filename), JSON.stringify(fullEntry, null, 2)); + } catch (err) { + console.error("[callLogs] Failed to write disk log:", err.message); + } +} + +/** + * Rotate old call log directories (keep last 7 days) + */ +export function rotateCallLogs() { + if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return; + + try { + const entries = fs.readdirSync(CALL_LOGS_DIR); + const now = Date.now(); + const sevenDays = 7 * 24 * 60 * 60 * 1000; + + for (const entry of entries) { + const entryPath = path.join(CALL_LOGS_DIR, entry); + const stat = fs.statSync(entryPath); + if (stat.isDirectory() && now - stat.mtimeMs > sevenDays) { + fs.rmSync(entryPath, { recursive: true, force: true }); + console.log(`[callLogs] Rotated old logs: ${entry}`); + } + } + } catch (err) { + console.error("[callLogs] Failed to rotate logs:", err.message); + } +} + +// Run rotation on startup +if (shouldPersistToDisk) { + try { + rotateCallLogs(); + } catch {} +} + +/** + * Get call logs with optional filtering + */ +export async function getCallLogs(filter = {}) { + const db = getDbInstance(); + let sql = "SELECT * FROM call_logs"; + const conditions = []; + const params = {}; + + if (filter.status) { + if (filter.status === "error") { + conditions.push("(status >= 400 OR error IS NOT NULL)"); + } else if (filter.status === "ok") { + conditions.push("status >= 200 AND status < 300"); + } else { + const statusCode = parseInt(filter.status); + if (!isNaN(statusCode)) { + conditions.push("status = @statusCode"); + params.statusCode = statusCode; + } + } + } + + if (filter.model) { + conditions.push("model LIKE @modelQ"); + params.modelQ = `%${filter.model}%`; + } + if (filter.provider) { + conditions.push("provider LIKE @providerQ"); + params.providerQ = `%${filter.provider}%`; + } + if (filter.account) { + conditions.push("account LIKE @accountQ"); + params.accountQ = `%${filter.account}%`; + } + if (filter.apiKey) { + conditions.push("(api_key_name LIKE @apiKeyQ OR api_key_id LIKE @apiKeyQ)"); + params.apiKeyQ = `%${filter.apiKey}%`; + } + if (filter.combo) { + conditions.push("combo_name IS NOT NULL"); + } + if (filter.search) { + conditions.push(`( + model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR + provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR + combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ + )`); + params.searchQ = `%${filter.search}%`; + } + + if (conditions.length > 0) { + sql += " WHERE " + conditions.join(" AND "); + } + + const limit = filter.limit || 200; + sql += ` ORDER BY timestamp DESC LIMIT ${limit}`; + + const rows = db.prepare(sql).all(params); + + return rows.map((l) => ({ + id: l.id, + timestamp: l.timestamp, + method: l.method, + path: l.path, + status: l.status, + model: l.model, + provider: l.provider, + account: l.account, + duration: l.duration, + tokens: { in: l.tokens_in, out: l.tokens_out }, + sourceFormat: l.source_format, + targetFormat: l.target_format, + error: l.error, + comboName: l.combo_name || null, + apiKeyId: l.api_key_id || null, + apiKeyName: l.api_key_name || null, + hasRequestBody: !!l.request_body, + hasResponseBody: !!l.response_body, + })); +} + +/** + * Get a single call log by ID (with full payloads from disk when available) + */ +export async function getCallLogById(id) { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM call_logs WHERE id = ?").get(id); + if (!row) return null; + + const entry = { + id: row.id, + timestamp: row.timestamp, + method: row.method, + path: row.path, + status: row.status, + model: row.model, + provider: row.provider, + account: row.account, + connectionId: row.connection_id, + duration: row.duration, + tokens: { in: row.tokens_in, out: row.tokens_out }, + sourceFormat: row.source_format, + targetFormat: row.target_format, + apiKeyId: row.api_key_id, + apiKeyName: row.api_key_name, + comboName: row.combo_name, + requestBody: row.request_body ? JSON.parse(row.request_body) : null, + responseBody: row.response_body ? JSON.parse(row.response_body) : null, + error: row.error, + }; + + // If payloads were truncated, try to read full version from disk + const needsDisk = entry.requestBody?._truncated || entry.responseBody?._truncated; + if (needsDisk && CALL_LOGS_DIR) { + try { + const diskEntry = readFullLogFromDisk(entry); + if (diskEntry) { + return { + ...entry, + requestBody: diskEntry.requestBody ?? entry.requestBody, + responseBody: diskEntry.responseBody ?? entry.responseBody, + }; + } + } catch (err) { + console.error("[callLogs] Failed to read full log from disk:", err.message); + } + } + + return entry; +} + +/** + * Read the full (untruncated) log entry from disk + */ +function readFullLogFromDisk(entry) { + if (!CALL_LOGS_DIR || !entry.timestamp) return null; + + try { + const date = new Date(entry.timestamp); + const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const dir = path.join(CALL_LOGS_DIR, dateFolder); + + if (!fs.existsSync(dir)) return null; + + const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}${String(date.getSeconds()).padStart(2, "0")}`; + const safeModel = (entry.model || "unknown").replace(/[/:]/g, "-"); + const expectedName = `${time}_${safeModel}_${entry.status}.json`; + + const exactPath = path.join(dir, expectedName); + if (fs.existsSync(exactPath)) { + return JSON.parse(fs.readFileSync(exactPath, "utf8")); + } + + const files = fs + .readdirSync(dir) + .filter((f) => f.startsWith(time) && f.endsWith(`_${entry.status}.json`)); + if (files.length > 0) { + return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8")); + } + } catch (err) { + console.error("[callLogs] Disk log read error:", err.message); + } + + return null; +} diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js new file mode 100644 index 0000000000..7b25c71fe6 --- /dev/null +++ b/src/mitm/cert/generate.js @@ -0,0 +1,41 @@ +import path from "path"; +import fs from "fs"; +import os from "os"; + +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; + +/** + * Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed) + */ +export async function generateCert() { + const certDir = path.join(os.homedir(), ".omniroute", "mitm"); + const keyPath = path.join(certDir, "server.key"); + const certPath = path.join(certDir, "server.crt"); + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + console.log("✅ SSL certificate already exists"); + return { key: keyPath, cert: certPath }; + } + + if (!fs.existsSync(certDir)) { + fs.mkdirSync(certDir, { recursive: true }); + } + + // Dynamic import for optional dependency + const { default: selfsigned } = await import("selfsigned"); + const attrs = [{ name: "commonName", value: TARGET_HOST }]; + const notAfter = new Date(); + notAfter.setFullYear(notAfter.getFullYear() + 1); + const pems = await selfsigned.generate(attrs, { + keySize: 2048, + algorithm: "sha256", + notAfterDate: notAfter, + extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: TARGET_HOST }] }], + }); + + fs.writeFileSync(keyPath, pems.private); + fs.writeFileSync(certPath, pems.cert); + + console.log(`✅ Generated SSL certificate for ${TARGET_HOST}`); + return { key: keyPath, cert: certPath }; +} diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js new file mode 100644 index 0000000000..b49e309b5e --- /dev/null +++ b/src/mitm/cert/install.js @@ -0,0 +1,139 @@ +import fs from "fs"; +import crypto from "crypto"; +import { exec } from "child_process"; +import { execWithPassword } from "../dns/dnsConfig.js"; + +const IS_WIN = process.platform === "win32"; + +// Get SHA1 fingerprint from cert file using Node.js crypto +function getCertFingerprint(certPath) { + const pem = fs.readFileSync(certPath, "utf-8"); + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64"); + return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":"); +} + +/** + * Check if certificate is already installed in system store + */ +export async function checkCertInstalled(certPath) { + if (IS_WIN) { + return checkCertInstalledWindows(certPath); + } + return checkCertInstalledMac(certPath); +} + +function checkCertInstalledMac(certPath) { + return new Promise((resolve) => { + try { + const fingerprint = getCertFingerprint(certPath); + exec( + `security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, + (error) => { + resolve(!error); + } + ); + } catch { + resolve(false); + } + }); +} + +function checkCertInstalledWindows(certPath) { + return new Promise((resolve) => { + // Check Root store for our cert by subject name + exec("certutil -store Root daily-cloudcode-pa.googleapis.com", (error) => { + resolve(!error); + }); + }); +} + +/** + * Install SSL certificate to system trust store + */ +export async function installCert(sudoPassword, certPath) { + if (!fs.existsSync(certPath)) { + throw new Error(`Certificate file not found: ${certPath}`); + } + + const isInstalled = await checkCertInstalled(certPath); + if (isInstalled) { + console.log("✅ Certificate already installed"); + return; + } + + if (IS_WIN) { + await installCertWindows(certPath); + } else { + await installCertMac(sudoPassword, certPath); + } +} + +async function installCertMac(sudoPassword, certPath) { + const command = `sudo -S security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`; + try { + await execWithPassword(command, sudoPassword); + console.log(`✅ Installed certificate to system keychain: ${certPath}`); + } catch (error) { + const msg = error.message?.includes("canceled") + ? "User canceled authorization" + : "Certificate install failed"; + throw new Error(msg); + } +} + +async function installCertWindows(certPath) { + // Use PowerShell elevated to add cert to Root store + const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${certPath.replace(/'/g, "''")}' -Verb RunAs -Wait`; + return new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) { + reject(new Error(`Failed to install certificate: ${error.message}`)); + } else { + console.log(`✅ Installed certificate to Windows Root store`); + resolve(); + } + }); + }); +} + +/** + * Uninstall SSL certificate from system store + */ +export async function uninstallCert(sudoPassword, certPath) { + const isInstalled = await checkCertInstalled(certPath); + if (!isInstalled) { + console.log("Certificate not found in system store"); + return; + } + + if (IS_WIN) { + await uninstallCertWindows(); + } else { + await uninstallCertMac(sudoPassword, certPath); + } +} + +async function uninstallCertMac(sudoPassword, certPath) { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + const command = `sudo -S security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`; + try { + await execWithPassword(command, sudoPassword); + console.log("✅ Uninstalled certificate from system keychain"); + } catch (err) { + throw new Error("Failed to uninstall certificate"); + } +} + +async function uninstallCertWindows() { + const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','daily-cloudcode-pa.googleapis.com' -Verb RunAs -Wait`; + return new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) { + reject(new Error(`Failed to uninstall certificate: ${error.message}`)); + } else { + console.log("✅ Uninstalled certificate from Windows Root store"); + resolve(); + } + }); + }); +} diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js new file mode 100644 index 0000000000..ae0cd477dc --- /dev/null +++ b/src/mitm/dns/dnsConfig.js @@ -0,0 +1,109 @@ +import { exec } from "child_process"; +import fs from "fs"; +import path from "path"; + +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const IS_WIN = process.platform === "win32"; +const HOSTS_FILE = IS_WIN + ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") + : "/etc/hosts"; + +/** + * Execute command with sudo password via stdin (macOS/Linux only) + */ +export function execWithPassword(command, password) { + return new Promise((resolve, reject) => { + const child = exec(command, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Command failed: ${error.message}\n${stderr}`)); + } else { + resolve(stdout); + } + }); + child.stdin.write(`${password}\n`); + child.stdin.end(); + }); +} + +/** + * Execute elevated command on Windows via PowerShell RunAs + */ +function execElevatedWindows(command) { + return new Promise((resolve, reject) => { + const psCommand = `Start-Process cmd -ArgumentList '/c','${command.replace(/'/g, "''")}' -Verb RunAs -Wait`; + exec(`powershell -Command "${psCommand}"`, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`)); + } else { + resolve(stdout); + } + }); + }); +} + +/** + * Check if DNS entry already exists + */ +export function checkDNSEntry() { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + return hostsContent.includes(TARGET_HOST); + } catch { + return false; + } +} + +/** + * Add DNS entry to hosts file + */ +export async function addDNSEntry(sudoPassword) { + if (checkDNSEntry()) { + console.log(`DNS entry for ${TARGET_HOST} already exists`); + return; + } + + const entry = `127.0.0.1 ${TARGET_HOST}`; + + try { + if (IS_WIN) { + // Windows: use elevated echo >> hosts + await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`); + } else { + const command = `echo "${entry}" | sudo -S tee -a ${HOSTS_FILE} > /dev/null`; + await execWithPassword(command, sudoPassword); + } + console.log(`✅ Added DNS entry: ${entry}`); + } catch (error) { + throw new Error(`Failed to add DNS entry: ${error.message}`); + } +} + +/** + * Remove DNS entry from hosts file + */ +export async function removeDNSEntry(sudoPassword) { + if (!checkDNSEntry()) { + console.log(`DNS entry for ${TARGET_HOST} does not exist`); + return; + } + + try { + if (IS_WIN) { + // Windows: read, filter, write back via elevated PowerShell + const psScript = `(Get-Content '${HOSTS_FILE}') | Where-Object { $_ -notmatch '${TARGET_HOST}' } | Set-Content '${HOSTS_FILE}'`; + const psCommand = `Start-Process powershell -ArgumentList '-Command','${psScript.replace(/'/g, "''")}' -Verb RunAs -Wait`; + await new Promise((resolve, reject) => { + exec(`powershell -Command "${psCommand}"`, (error) => { + if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`)); + else resolve(); + }); + }); + } else { + const command = `sudo -S sed -i '' '/${TARGET_HOST}/d' ${HOSTS_FILE}`; + await execWithPassword(command, sudoPassword); + } + console.log(`✅ Removed DNS entry for ${TARGET_HOST}`); + } catch (error) { + throw new Error(`Failed to remove DNS entry: ${error.message}`); + } +} diff --git a/src/mitm/manager.js b/src/mitm/manager.js new file mode 100644 index 0000000000..47ce7e4ffa --- /dev/null +++ b/src/mitm/manager.js @@ -0,0 +1,239 @@ +import { spawn } from "child_process"; +import path from "path"; +import fs from "fs"; +import os from "os"; +import { addDNSEntry, removeDNSEntry } from "./dns/dnsConfig.js"; +import { generateCert } from "./cert/generate.js"; +import { installCert } from "./cert/install.js"; + +// Store server process +let serverProcess = null; +let serverPid = null; + +// Module-scoped password cache (not exposed on globalThis). +// Cleared automatically when the MITM proxy is stopped. +let _cachedPassword = null; +export function getCachedPassword() { + return _cachedPassword; +} +export function setCachedPassword(pwd) { + _cachedPassword = pwd || null; +} +export function clearCachedPassword() { + _cachedPassword = null; +} + +// server.js is in same directory as this file +const PID_FILE = path.join(os.homedir(), ".omniroute", "mitm", ".mitm.pid"); + +// Check if a PID is alive +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Get MITM status + */ +export async function getMitmStatus() { + // Check in-memory process first, then fallback to PID file + let running = serverProcess !== null && !serverProcess.killed; + let pid = serverPid; + + if (!running) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + running = true; + pid = savedPid; + } else { + // Stale PID file, clean up + fs.unlinkSync(PID_FILE); + } + } + } catch { + // Ignore + } + } + + // Check DNS configuration + let dnsConfigured = false; + try { + const hostsContent = fs.readFileSync("/etc/hosts", "utf-8"); + dnsConfigured = hostsContent.includes("daily-cloudcode-pa.googleapis.com"); + } catch { + // Ignore + } + + // Check cert + const certDir = path.join(os.homedir(), ".omniroute", "mitm"); + const certExists = fs.existsSync(path.join(certDir, "server.crt")); + + return { running, pid, dnsConfigured, certExists }; +} + +/** + * Start MITM proxy + * @param {string} apiKey - OmniRoute API key + * @param {string} sudoPassword - Sudo password for DNS/cert operations + */ +export async function startMitm(apiKey, sudoPassword) { + // Check if already running + if (serverProcess && !serverProcess.killed) { + throw new Error("MITM proxy is already running"); + } + + // 1. Generate SSL certificate if not exists + const certPath = path.join(os.homedir(), ".omniroute", "mitm", "server.crt"); + if (!fs.existsSync(certPath)) { + console.log("Generating SSL certificate..."); + await generateCert(); + } + + // 2. Install certificate to system keychain + await installCert(sudoPassword, certPath); + + // 3. Add DNS entry + console.log("Adding DNS entry..."); + await addDNSEntry(sudoPassword); + + // 4. Start MITM server + console.log("Starting MITM server..."); + const serverPath = path.join(process.cwd(), "src/mitm/server.js"); + serverProcess = spawn("node", [serverPath], { + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + }, + detached: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + serverPid = serverProcess.pid; + + // Save PID to file + fs.writeFileSync(PID_FILE, String(serverPid)); + + // Log server output + serverProcess.stdout.on("data", (data) => { + console.log(`[MITM Server] ${data.toString().trim()}`); + }); + + serverProcess.stderr.on("data", (data) => { + console.error(`[MITM Server Error] ${data.toString().trim()}`); + }); + + serverProcess.on("exit", (code) => { + console.log(`MITM server exited with code ${code}`); + serverProcess = null; + serverPid = null; + + // Remove PID file + try { + fs.unlinkSync(PID_FILE); + } catch (error) { + // Ignore + } + }); + + // Wait and verify server actually started + const started = await new Promise((resolve) => { + let resolved = false; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + resolve(true); + } + }, 2000); + + serverProcess.on("exit", (code) => { + clearTimeout(timeout); + if (!resolved) { + resolved = true; + resolve(false); + } + }); + + // Check stderr for error messages + serverProcess.stderr.on("data", (data) => { + const msg = data.toString().trim(); + if (msg.includes("Port") && msg.includes("already in use")) { + clearTimeout(timeout); + if (!resolved) { + resolved = true; + resolve(false); + } + } + }); + }); + + if (!started) { + throw new Error("MITM server failed to start (port 443 may be in use)"); + } + + return { + running: true, + pid: serverPid, + }; +} + +/** + * Stop MITM proxy + * @param {string} sudoPassword - Sudo password for DNS cleanup + */ +export async function stopMitm(sudoPassword) { + // 1. Kill server process (in-memory or from PID file) + const proc = serverProcess; + if (proc && !proc.killed) { + console.log("Stopping MITM server..."); + proc.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (!proc.killed) { + proc.kill("SIGKILL"); + } + serverProcess = null; + serverPid = null; + } else { + // Fallback: kill by PID file + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + console.log(`Killing MITM server (PID: ${savedPid})...`); + process.kill(savedPid, "SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (isProcessAlive(savedPid)) { + process.kill(savedPid, "SIGKILL"); + } + } + } + } catch { + // Ignore + } + serverProcess = null; + serverPid = null; + } + + // 2. Remove DNS entry + console.log("Removing DNS entry..."); + await removeDNSEntry(sudoPassword); + + // 3. Clean up + clearCachedPassword(); // Clear password from memory when proxy stops + try { + fs.unlinkSync(PID_FILE); + } catch (error) { + // Ignore + } + + return { + running: false, + pid: null, + }; +} diff --git a/src/mitm/server.js b/src/mitm/server.js new file mode 100644 index 0000000000..d825ca49c4 --- /dev/null +++ b/src/mitm/server.js @@ -0,0 +1,224 @@ +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const dns = require("dns"); +const { promisify } = require("util"); +const os = require("os"); + +// Configuration +const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const LOCAL_PORT = 443; +const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; +const API_KEY = process.env.ROUTER_API_KEY; +const DB_FILE = path.join(os.homedir(), ".omniroute", "db.json"); + +// Toggle logging (set true to enable file logging for debugging) +const ENABLE_FILE_LOG = false; + +if (!API_KEY) { + console.error("❌ ROUTER_API_KEY required"); + process.exit(1); +} + +// Load SSL certificates +const certDir = path.join(os.homedir(), ".omniroute", "mitm"); +const sslOptions = { + key: fs.readFileSync(path.join(certDir, "server.key")), + cert: fs.readFileSync(path.join(certDir, "server.crt")), +}; + +// Chat endpoints that should be intercepted +const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; + +// Log directory for request/response dumps +const LOG_DIR = path.join(__dirname, "../../logs/mitm"); +if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + +function saveRequestLog(url, bodyBuffer) { + if (!ENABLE_FILE_LOG) return; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); + const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`); + const body = JSON.parse(bodyBuffer.toString()); + fs.writeFileSync(filePath, JSON.stringify(body, null, 2)); + console.log(`💾 Saved request: ${filePath}`); + } catch { + // Ignore + } +} + +function saveResponseLog(url, data) { + if (!ENABLE_FILE_LOG) return; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); + const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`); + fs.writeFileSync(filePath, data); + console.log(`💾 Saved response: ${filePath}`); + } catch { + // Ignore + } +} + +// Resolve real IP of target host (bypass /etc/hosts) +let cachedTargetIP = null; +async function resolveTargetIP() { + if (cachedTargetIP) return cachedTargetIP; + const resolver = new dns.Resolver(); + resolver.setServers(["8.8.8.8"]); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(TARGET_HOST); + cachedTargetIP = addresses[0]; + return cachedTargetIP; +} + +function collectBodyRaw(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function extractModel(body) { + try { + return JSON.parse(body.toString()).model || null; + } catch { + return null; + } +} + +function getMappedModel(model) { + if (!model) return null; + try { + const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); + return db.mitmAlias?.antigravity?.[model] || null; + } catch { + return null; + } +} + +async function passthrough(req, res, bodyBuffer) { + const targetIP = await resolveTargetIP(); + + const forwardReq = https.request( + { + hostname: targetIP, + port: 443, + path: req.url, + method: req.method, + headers: { ...req.headers, host: TARGET_HOST }, + servername: TARGET_HOST, + rejectUnauthorized: false, + }, + (forwardRes) => { + res.writeHead(forwardRes.statusCode, forwardRes.headers); + forwardRes.pipe(res); + } + ); + + forwardReq.on("error", (err) => { + console.error(`❌ Passthrough error: ${err.message}`); + if (!res.headersSent) res.writeHead(502); + res.end("Bad Gateway"); + }); + + if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer); + forwardReq.end(); +} + +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + + const response = await fetch(ROUTER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error(`OmniRoute ${response.status}: ${errText}`); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + res.end(); + break; + } + res.write(decoder.decode(value, { stream: true })); + } + } catch (error) { + console.error(`❌ ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +const server = https.createServer(sslOptions, async (req, res) => { + const bodyBuffer = await collectBodyRaw(req); + + // Save request log if enabled + if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); + + // Anti-loop: requests from OmniRoute bypass interception + if (req.headers["x-omniroute-source"] === "omniroute") { + return passthrough(req, res, bodyBuffer); + } + + const isChatRequest = CHAT_URL_PATTERNS.some((p) => req.url.includes(p)); + + if (!isChatRequest) { + return passthrough(req, res, bodyBuffer); + } + + const model = extractModel(bodyBuffer); + const mappedModel = getMappedModel(model); + + if (!mappedModel) { + return passthrough(req, res, bodyBuffer); + } + + console.log(`🔀 ${model} → ${mappedModel}`); + return intercept(req, res, bodyBuffer, mappedModel); +}); + +server.listen(LOCAL_PORT, () => { + console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`); +}); + +server.on("error", (error) => { + if (error.code === "EADDRINUSE") { + console.error(`❌ Port ${LOCAL_PORT} already in use`); + } else if (error.code === "EACCES") { + console.error(`❌ Permission denied for port ${LOCAL_PORT}`); + } else { + console.error(`❌ ${error.message}`); + } + process.exit(1); +}); + +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); +process.on("SIGINT", () => { + server.close(() => process.exit(0)); +}); diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 0000000000..637b6f3b90 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,24 @@ +// Database Models - Export all from localDb +export { + getProviderConnections, + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, + deleteProviderConnection, + getProviderNodes, + getProviderNodeById, + createProviderNode, + updateProviderNode, + deleteProviderNode, + deleteProviderConnectionsByProvider, + getModelAliases, + setModelAlias, + deleteModelAlias, + getMitmAlias, + setMitmAliasAll, + getApiKeys, + createApiKey, + deleteApiKey, + validateApiKey, + isCloudEnabled, +} from "@/lib/localDb"; diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 0000000000..ec8b282431 --- /dev/null +++ b/src/proxy.js @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { jwtVerify } from "jose"; + +const SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "omniroute-default-secret-change-me" +); + +export async function proxy(request) { + const { pathname } = request.nextUrl; + + // Protect all dashboard routes (except onboarding) + if (pathname.startsWith("/dashboard")) { + // Always allow onboarding — it has its own setupComplete guard + if (pathname.startsWith("/dashboard/onboarding")) { + return NextResponse.next(); + } + + const token = request.cookies.get("auth_token")?.value; + + if (token) { + try { + await jwtVerify(token, SECRET); + return NextResponse.next(); + } catch (err) { + return NextResponse.redirect(new URL("/login", request.url)); + } + } + + const origin = request.nextUrl.origin; + try { + const res = await fetch(`${origin}/api/settings`); + const data = await res.json(); + // Skip auth if login is not required + if (data.requireLogin === false) { + return NextResponse.next(); + } + // Skip auth if no password has been set yet (fresh install) + // This prevents an unresolvable loop where requireLogin=true but no password exists + if (!data.hasPassword) { + return NextResponse.next(); + } + } catch (err) { + // On error, require login + } + return NextResponse.redirect(new URL("/login", request.url)); + } + + // Redirect / to /dashboard if logged in, or /dashboard if it's the root + if (pathname === "/") { + return NextResponse.redirect(new URL("/dashboard", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/", "/dashboard/:path*"], +}; diff --git a/src/server-init.js b/src/server-init.js new file mode 100644 index 0000000000..d32b0b1329 --- /dev/null +++ b/src/server-init.js @@ -0,0 +1,21 @@ +// Server startup script +import initializeCloudSync from "./shared/services/initializeCloudSync.js"; + +async function startServer() { + console.log("Starting server with cloud sync..."); + + try { + // Initialize cloud sync + await initializeCloudSync(); + console.log("Server started with cloud sync initialized"); + } catch (error) { + console.log("Error initializing cloud sync:", error); + process.exit(1); + } +} + +// Start the server initialization +startServer().catch(console.log); + +// Export for use as module if needed +export default startServer; diff --git a/src/shared/components/Avatar.js b/src/shared/components/Avatar.js new file mode 100644 index 0000000000..b2f48e0320 --- /dev/null +++ b/src/shared/components/Avatar.js @@ -0,0 +1,81 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Avatar({ src, alt = "Avatar", name, size = "md", className }) { + const sizes = { + xs: "size-6 text-xs", + sm: "size-8 text-sm", + md: "size-10 text-base", + lg: "size-12 text-lg", + xl: "size-16 text-xl", + }; + + // Get initials from name + const getInitials = (name) => { + if (!name) return "?"; + const parts = name.split(" "); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + // Generate color from name + const getColorFromName = (name) => { + if (!name) return "bg-primary"; + const colors = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-emerald-500", + "bg-teal-500", + "bg-cyan-500", + "bg-sky-500", + "bg-blue-500", + "bg-indigo-500", + "bg-violet-500", + "bg-purple-500", + "bg-fuchsia-500", + "bg-pink-500", + "bg-rose-500", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index]; + }; + + if (src) { + return ( +
+ ); + } + + return ( +
+ {getInitials(name)} +
+ ); +} diff --git a/src/shared/components/Badge.js b/src/shared/components/Badge.js new file mode 100644 index 0000000000..9087be8371 --- /dev/null +++ b/src/shared/components/Badge.js @@ -0,0 +1,59 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + default: "bg-black/5 dark:bg-white/10 text-text-muted", + primary: "bg-primary/10 text-primary", + success: "bg-green-500/10 text-green-600 dark:text-green-400", + warning: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + error: "bg-red-500/10 text-red-600 dark:text-red-400", + info: "bg-blue-500/10 text-blue-600 dark:text-blue-400", +}; + +const sizes = { + sm: "px-2 py-0.5 text-[10px]", + md: "px-2.5 py-1 text-xs", + lg: "px-3 py-1.5 text-sm", +}; + +export default function Badge({ + children, + variant = "default", + size = "md", + dot = false, + icon, + className, +}) { + return ( + + {dot && ( + + ); +} diff --git a/src/shared/components/Button.js b/src/shared/components/Button.js new file mode 100644 index 0000000000..7f1d4efb9b --- /dev/null +++ b/src/shared/components/Button.js @@ -0,0 +1,73 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + primary: "bg-gradient-to-b from-primary to-primary-hover text-white shadow-sm", + secondary: + "bg-white dark:bg-white/10 border border-black/10 dark:border-white/10 text-text-main hover:bg-black/5 dark:hover:bg-white/5", + outline: "border border-black/15 dark:border-white/15 text-text-main hover:bg-black/5", + ghost: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-text-main", + danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm", +}; + +const sizes = { + sm: "h-7 px-3 text-xs rounded-md", + md: "h-9 px-4 text-sm rounded-lg", + lg: "h-11 px-6 text-sm rounded-lg", +}; + +export default function Button({ + children, + variant = "primary", + size = "md", + icon, + iconRight, + disabled = false, + loading = false, + fullWidth = false, + className, + ...props +}) { + return ( + + ); +} diff --git a/src/shared/components/Card.js b/src/shared/components/Card.js new file mode 100644 index 0000000000..1e8e26cecc --- /dev/null +++ b/src/shared/components/Card.js @@ -0,0 +1,112 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Card({ + children, + title, + subtitle, + icon, + action, + padding = "md", + hover = false, + className, + ...props +}) { + const paddings = { + none: "", + xs: "p-3", + sm: "p-4", + md: "p-6", + lg: "p-8", + }; + + return ( +
+ {(title || action) && ( +
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+
+ {action} +
+ )} + {children} +
+ ); +} + +// Sub-component: Bordered section inside Card +Card.Section = function CardSection({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +// Sub-component: Hoverable row inside Card +Card.Row = function CardRow({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +// Sub-component: List item with hover actions (macOS style) +Card.ListItem = function CardListItem({ children, actions, className, ...props }) { + return ( +
+
{children}
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +}; diff --git a/src/shared/components/CursorAuthModal.js b/src/shared/components/CursorAuthModal.js new file mode 100644 index 0000000000..0bfb9c1ee9 --- /dev/null +++ b/src/shared/components/CursorAuthModal.js @@ -0,0 +1,194 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; + +/** + * Cursor Auth Modal + * Auto-detect and import token from Cursor IDE's local SQLite database + */ +export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { + const [accessToken, setAccessToken] = useState(""); + const [machineId, setMachineId] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [autoDetecting, setAutoDetecting] = useState(false); + const [autoDetected, setAutoDetected] = useState(false); + + // Auto-detect tokens when modal opens + useEffect(() => { + if (!isOpen) return; + + const autoDetect = async () => { + setAutoDetecting(true); + setError(null); + setAutoDetected(false); + + try { + const res = await fetch("/api/oauth/cursor/auto-import"); + const data = await res.json(); + + if (data.found) { + setAccessToken(data.accessToken); + setMachineId(data.machineId); + setAutoDetected(true); + } else { + setError(data.error || "Could not auto-detect tokens"); + } + } catch (err) { + setError("Failed to auto-detect tokens"); + } finally { + setAutoDetecting(false); + } + }; + + autoDetect(); + }, [isOpen]); + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Please enter an access token"); + return; + } + + if (!machineId.trim()) { + setError("Please enter a machine ID"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/cursor/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: accessToken.trim(), + machineId: machineId.trim(), + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + // Success - close modal and trigger refresh + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + return ( + +
+ {/* Auto-detecting state */} + {autoDetecting && ( +
+
+ + progress_activity + +
+

Auto-detecting tokens...

+

Reading from Cursor IDE database

+
+ )} + + {/* Form (shown after auto-detect completes) */} + {!autoDetecting && ( + <> + {/* Success message if auto-detected */} + {autoDetected && ( +
+
+ + check_circle + +

+ Tokens auto-detected from Cursor IDE successfully! +

+
+
+ )} + + {/* Info message if not auto-detected */} + {!autoDetected && !error && ( +
+
+ + info + +

+ Cursor IDE not detected. Please paste your tokens manually. +

+
+
+ )} + + {/* Access Token Input */} +
+ +